Private/Kinds/Website.ps1
|
# The Website Kind: whether a page loads when a browser loads it, and what happened at # every host it needed on the way. # # A website is not one request. The HttpsEndpoint Kind asks one address one question; a # page is dozens of requests to a sign-in host, a CDN and an API, and one of them failing # is the usual reason "the intranet does not load". What a browser met on the way is in # its net log, so this Kind has a browser load the page and write one: Gutcheck starts its # own Edge (Chrome where there is no Edge), headless, with a profile folder of its own # that is deleted afterwards, and reads the net log it wrote. # # A Check that acts, like the disk write test: it loads a page and writes two things, both # Gutcheck's own - the throwaway profile, which it deletes, and the net log, which it keeps # in the Report folder for second level. The User's browser, profile, cookies and # extensions are not touched; the browser process Gutcheck started is stopped when the # page takes longer than the time limit, which is part of the measurement. # # The net log is recorded in Chromium's default capture mode, which leaves out cookies and # credentials. It still holds every address the page loaded, so it is Customer data. # # Each entry of the Sites parameter is an object: # Url http:// or https:// address of the page (required) # Title what the Report calls the site (default: its host and path) # App the application the site belongs to; its Findings stand under it # ExpectStatus the final statuses that count as answered: numbers, or ranges written # "200-399" (default 200-399) # The browser's switches. The first group is what makes it a browser of its own: no window, # no profile but the throwaway one, nothing of the User's. The second keeps Edge from # fetching for itself while it loads the page - updates, sync, extensions, its first-run # screens - which the net log would otherwise be full of. It does some all the same # (SmartScreen, its time service, measured 2026-10-14), which is why the parse attributes # requests to the page and does not rely on these. $script:WebsiteBrowserSwitches = @( '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check' '--disable-background-networking', '--disable-component-update', '--disable-sync' '--disable-extensions', '--disable-default-apps', '--disable-breakpad' ) # How much time the page's own timers are given after it has loaded, in the browser's # virtual time: a page that fetches more a moment after its load event gets to, and the # real time this costs is short because virtual time runs as fast as the page lets it. $script:WebsiteVirtualTimeMs = 8000 # How long one page may take before the browser is stopped, where a Definition says nothing. # Not a judgement of how fast a page should be - the Judge's thresholds are that - but a # bound on how long a hanging site can hold up the Run. $script:WebsiteTimeLimitSeconds = 45 function ConvertTo-WebsiteTarget { <# .SYNOPSIS Reads one entry of the Sites parameter into what to load and what to expect. Pure, and reaches nothing. .DESCRIPTION Both halves of the Kind read entries through here, so the Gatherer loads exactly what the Judge will hold the answer against. A broken entry comes back with a Problem rather than being dropped: the Report names a mistyped Definition instead of calling a site unreachable. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Entry) $url = "$(Get-DataProperty $Entry 'Url')".Trim() $title = "$(Get-DataProperty $Entry 'Title')".Trim() $app = "$(Get-DataProperty $Entry 'App')".Trim() # A number, or a range written "200-399". What is neither is left out, and nothing # left is the default: a site answers when it ends at a page, not at an error. $expect = @(foreach ($item in @(Get-DataProperty $Entry 'ExpectStatus')) { if ($null -eq $item) { continue } if ("$item".Trim() -match '^(?<from>\d{3})\s*-\s*(?<to>\d{3})$') { [pscustomobject]@{ From = [int]$Matches['from']; To = [int]$Matches['to'] } continue } $number = ConvertTo-Number $item if ($null -ne $number) { [pscustomobject]@{ From = [int]$number; To = [int]$number } } }) if (-not $expect.Count) { $expect = @([pscustomobject]@{ From = 200; To = 399 }) } $checked = Test-WebsiteAddress -Address $url $uri = $checked.Uri $problem = $null if ($checked.Refused) { $problem = 'NotWebAddress' } $label = $title if (-not $label -and $uri) { $label = $uri.Host + $uri.AbsolutePath.TrimEnd('/') } if (-not $label) { $label = $url } [pscustomobject]@{ PSTypeName = 'Gutcheck.WebsiteTarget' Url = $url Key = $url HostName = $(if ($uri) { $uri.Host } else { $null }) Label = $label Title = $title App = $app ExpectStatus = $expect Problem = $problem } } function Test-WebsiteAddress { <# .SYNOPSIS Whether an address is one the browser can be handed: absolute, http or https, with a host, and nothing that could end its argument on the command line. Pure. .DESCRIPTION Refused is 'Malformed', 'Scheme' or empty; Uri the address read, where it was one. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Address) $uri = $null $refused = '' if ("$Address" -match '[\s"]' -or -not [uri]::TryCreate("$Address", [UriKind]::Absolute, [ref]$uri) -or -not $uri.Host) { $refused = 'Malformed' $uri = $null } elseif ($uri.Scheme -notin 'http', 'https') { $refused = 'Scheme' $uri = $null } [pscustomobject]@{ Uri = $uri; Refused = $refused } } function ConvertTo-WebsiteUrlCheck { <# .SYNOPSIS The addresses a Technician named with -Url as Website Check Definitions, one per address and named after its host. Throws for one that is no web address. Pure. .DESCRIPTION Built at run time and written to no Definitions file: the site a User complains about is examined without anybody writing a Definition for it. An address without a scheme is read as https, which is what a Technician typing "intranet.contoso" means. One that is not a web address at all stops the Run before it starts, with the reason: a typo must not cost a Run. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()][string[]]$Url = @()) $names = @{} foreach ($typed in @($Url | ForEach-Object { "$_".Trim() } | Where-Object { $_ })) { $address = $typed if ($address -notmatch '^[a-z][a-z0-9+.-]*://') { $address = 'https://' + $address } $checked = Test-WebsiteAddress -Address $address $uri = $checked.Uri $reason = switch ($checked.Refused) { 'Malformed' { Get-Text 'Value.Website.Refused.Malformed' } 'Scheme' { Get-Text 'Value.Website.Refused.Scheme' } } if ($reason) { throw ((Get-Text 'Error.Website.NotAnAddress') -f $typed, $reason) } $name = $uri.Host if ($names.ContainsKey($name)) { $names[$name]++; $name = '{0} ({1})' -f $name, $names[$name] } else { $names[$name] = 1 } [pscustomobject]@{ Name = $name Kind = 'Website' MinimumModuleVersion = $null Parameters = @{ Sites = @(@{ Url = $address }) } } } } function Read-WebsiteUrlCheck { <# .SYNOPSIS Asks in the window which websites to load, and returns them as Website Checks, as -Url makes them. Nothing for an empty answer. .DESCRIPTION Asked where a Run's Scope is for websites and none was named. Several addresses are separated by spaces or commas. One that is no web address is said, with why, and the question is asked again: a typo must not cost the Run its website. #> [CmdletBinding()] [OutputType([psobject])] param() while ($true) { $answer = Read-LanternText (Get-Text 'Console.Website.Question') $typed = @("$answer" -split '[\s,;]+' | Where-Object { $_ }) if (-not $typed.Count) { return } try { return @(ConvertTo-WebsiteUrlCheck -Url $typed) } catch { Write-ConsoleLine $_.Exception.Message -Color Yellow } } } function New-WebsiteNotGivenFinding { <# .SYNOPSIS The Finding that says a Run for websites loaded none, because none was given. Nothing where one was, or the Scope was for something else. Pure. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Selection) if (-not [bool](Get-DataProperty $Selection 'WebsiteMissing')) { return } New-Finding -Category Website -Check (Get-Text 'Check.Website.Sites') -Severity INFO ` -Value (Get-Text 'Value.Website.NoneGiven') -Hint (Get-Text 'Hint.Website.NoneGiven') } function Find-WebsiteBrowser { <# .SYNOPSIS The browser that loads the page: Edge where it is installed, Chrome where it is not, or $null where neither is. .DESCRIPTION Where Windows registers it (App Paths) first, then where its installer puts it, machine-wide and, for Chrome, per user. Edge first because it is on every Windows and is what the User most likely opens the site in. #> [CmdletBinding()] [OutputType([psobject])] param() $candidates = @( @{ Name = 'Edge'; Paths = @( (Join-Path "${env:ProgramFiles(x86)}" 'Microsoft\Edge\Application\msedge.exe') (Join-Path "$env:ProgramFiles" 'Microsoft\Edge\Application\msedge.exe')) } @{ Name = 'Chrome'; Paths = @( (Join-Path "$env:ProgramFiles" 'Google\Chrome\Application\chrome.exe') (Join-Path "${env:ProgramFiles(x86)}" 'Google\Chrome\Application\chrome.exe') (Join-Path "$env:LOCALAPPDATA" 'Google\Chrome\Application\chrome.exe')) } ) foreach ($candidate in $candidates) { # Where Windows registers it, as the Browser Kind asks. See BrowserProfile.ps1. $registered = Get-BrowserExecutable -Browser $candidate.Name foreach ($path in @($registered) + @($candidate.Paths)) { $path = "$path".Trim('"', ' ') if (-not $path -or -not [IO.File]::Exists($path)) { continue } $version = $null try { $version = [Diagnostics.FileVersionInfo]::GetVersionInfo($path).ProductVersion } catch { } return [pscustomobject]@{ Name = $candidate.Name; Path = $path; Version = $version } } } $null } function Invoke-WebsiteLoad { <# .SYNOPSIS Has the browser load one page with a profile of its own and a net log, and reads the net log. Judges nothing, and never throws. .DESCRIPTION The browser is started directly, with its arguments as one command line: through Start-Process with quoted arguments, Edge ended at once with exit code 13 (observed 2026-10-14). It is given a profile folder of its own, which keeps it apart from a browser the User has open - Chromium runs one browser per profile - and is deleted afterwards. --dump-dom is what makes a headless browser end once the page has loaded; what it writes is the page itself, which is read off its output and dropped, never kept: the content of a page is not Gutcheck's. A page that is not done within the time limit is stopped, the browser's whole process tree with it. Its net log is then cut off mid-write, which the parse reads up to where it ends. The net log is written into the Report folder where there is one. Where there is none - a Check performed outside a Run - it goes into the throwaway folder and is deleted with it. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)]$Target, [Parameter(Mandatory)]$Browser, [AllowEmptyString()][string]$Folder = '', [int]$TimeLimitSeconds = $script:WebsiteTimeLimitSeconds ) $record = [ordered]@{ Key = $Target.Key Url = $Target.Url Label = $Target.Label Title = $Target.Title App = $Target.App Browser = $Browser.Name TimedOut = $false ExitCode = $null ElapsedMs = $null NetLog = $null Page = $null Error = $null ProfileLeft = $null } $scratch = [IO.Path]::Combine([IO.Path]::GetTempPath(), 'gutcheck-website-' + [guid]::NewGuid().ToString('N')) $profileFolder = [IO.Path]::Combine($scratch, 'profile') # One name per host, as a file system takes it; two sites on one host each keep their own. $stem = 'netlog_{0}' -f ($Target.HostName -replace '[^A-Za-z0-9.-]', '_') $fileName = $stem + '.json' $logPath = [IO.Path]::Combine($scratch, $fileName) $kept = $Folder -and [IO.Directory]::Exists($Folder) if ($kept) { $logPath = [IO.Path]::Combine($Folder, $fileName) $n = 1 while ([IO.File]::Exists($logPath)) { $n++ $logPath = [IO.Path]::Combine($Folder, ('{0}_{1}.json' -f $stem, $n)) } } $process = $null $watch = [Diagnostics.Stopwatch]::StartNew() try { $null = [IO.Directory]::CreateDirectory($profileFolder) $arguments = @($script:WebsiteBrowserSwitches) + @( ('--user-data-dir="{0}"' -f $profileFolder) ('--log-net-log="{0}"' -f $logPath) # Said, although it is the default: cookies and credentials stay out of the file. '--net-log-capture-mode=Default' ('--virtual-time-budget={0}' -f $script:WebsiteVirtualTimeMs) # What makes a headless browser end once the page has loaded. The page itself, # which it writes to its output, is drained below and never kept. '--dump-dom' ('"{0}"' -f $Target.Url) ) $info = New-Object System.Diagnostics.ProcessStartInfo $info.FileName = $Browser.Path $info.Arguments = $arguments -join ' ' $info.UseShellExecute = $false $info.CreateNoWindow = $true $info.RedirectStandardOutput = $true $info.RedirectStandardError = $true $process = [Diagnostics.Process]::Start($info) # Read to the end so the browser never waits on a full pipe, and dropped. $null = $process.StandardOutput.ReadToEndAsync() $null = $process.StandardError.ReadToEndAsync() $status = Get-Text 'Console.Progress.Website' while (-not $process.WaitForExit(250)) { $seconds = $watch.Elapsed.TotalSeconds Write-CheckProgress -Status ($status -f $Target.Label, [int]$seconds, $TimeLimitSeconds) -Step $seconds -Of $TimeLimitSeconds if ($seconds -ge $TimeLimitSeconds) { $record.TimedOut = $true Stop-WebsiteBrowser -Process $process break } } $record.ElapsedMs = [math]::Round($watch.Elapsed.TotalMilliseconds) if ($process.HasExited) { $record.ExitCode = $process.ExitCode } if ([IO.File]::Exists($logPath)) { $text = $null # A browser that was stopped may hold the file a moment longer. for ($attempt = 0; $attempt -lt 10 -and $null -eq $text; $attempt++) { try { $text = [IO.File]::ReadAllText($logPath) } catch { Start-Sleep -Milliseconds 300 } } $record.Page = ConvertFrom-WebsiteNetLog -Text $text -Url $Target.Url if ($kept) { $record.NetLog = [IO.Path]::GetFileName($logPath) } } } catch { $record.Error = $_.Exception.GetBaseException().Message } finally { if ($process -and -not $process.HasExited) { Stop-WebsiteBrowser -Process $process } if ($process) { $process.Dispose() } # The profile goes, whatever happened above. Its files may be held for a moment # by a browser process that is still ending. for ($attempt = 0; $attempt -lt 20 -and [IO.Directory]::Exists($scratch); $attempt++) { try { [IO.Directory]::Delete($scratch, $true) } catch { Start-Sleep -Milliseconds 250 } } if ([IO.Directory]::Exists($scratch)) { $record.ProfileLeft = $scratch } } [pscustomobject]$record } function Stop-WebsiteBrowser { <# .SYNOPSIS Stops a browser Gutcheck started, with every process it started in turn. .DESCRIPTION A Chromium browser is a tree of processes. PowerShell 7 can end a tree itself; Windows PowerShell's .NET Framework cannot, and taskkill does it there. #> [CmdletBinding()] param([Parameter(Mandatory)][Diagnostics.Process]$Process) try { if ($PSVersionTable.PSEdition -eq 'Core') { $Process.Kill($true) } else { $null = & "$env:SystemRoot\System32\taskkill.exe" /PID $Process.Id /T /F 2>&1 } $null = $Process.WaitForExit(5000) } catch { } } # What Get-WebsiteData hands its Judge. See Private/DataShape.ps1. $script:WebsiteDataShape = @{ GatheredAt = 'Answered' Browser = 'Value' NoBrowser = 'Answered' TimeLimitSeconds = 'Answered' BrowserObserved = 'Answered' BrowserPolicies = 'List' Sites = 'List' Prefixes = 'List' Routes = 'List' NetworkObserved = 'Answered' UserProxy = 'Value' WinHttpProxy64Text = 'Value' WinHttpProxy32Text = 'Value' } function Get-WebsiteData { <# .SYNOPSIS Loads each site of the Definition in a browser of Gutcheck's own and reads what its net log says. Decides nothing. .PARAMETER Observed What the Run knows before this Check. 'RunFolder' is the folder the Report is written to, where the net logs are kept; see Private/Kind.ps1. #> [CmdletBinding()] [OutputType([psobject])] param([hashtable]$Parameters = @{}, [AllowNull()][hashtable]$Observed = @{}) $targets = @(@(Get-Parameter $Parameters 'Sites' @()) | Where-Object { $null -ne $_ } | ForEach-Object { ConvertTo-WebsiteTarget -Entry $_ } | Where-Object { -not $_.Problem }) # How long one page may take before the browser is stopped. Not a judgement of how # fast it should be - the Judge's thresholds are that - but a bound on how long a # hanging site can hold up the Run. $limit = ConvertTo-Number (Get-Parameter $Parameters 'TimeLimitSeconds' $script:WebsiteTimeLimitSeconds) if ($null -eq $limit -or $limit -lt 1) { $limit = $script:WebsiteTimeLimitSeconds } $folder = "$(Get-DataProperty (Get-ObservedKindData -Observed $Observed -Kind 'RunFolder') 'Path')" $network = Get-ObservedKindData -Observed $Observed -Kind 'Network' $browsers = Get-ObservedKindData -Observed $Observed -Kind 'Browser' $browser = $null if ($targets.Count) { $browser = Find-WebsiteBrowser } $sites = @(if ($browser) { foreach ($target in $targets) { Invoke-WebsiteLoad -Target $target -Browser $browser -Folder $folder -TimeLimitSeconds ([int]$limit) } }) # What the Judge decides each host's Zone from: the machine's own subnets, and the # adapter the route to each address the page connected to leaves through. Read, not # judged; see Get-OwnPrefix and Get-AddressRoute. $addresses = @(foreach ($site in $sites) { foreach ($h in (Get-DataCollection (Get-DataProperty $site 'Page') 'Hosts')) { if ("$(Get-DataProperty $h 'RemoteAddress')" -match '^\[?(?<address>[^\]]+?)\]?:\d+$') { $Matches['address'] } } }) $addresses = @($addresses | Where-Object { $_ } | Select-Object -Unique) [pscustomobject]@{ PSTypeName = 'Gutcheck.Data.Website' # When it was read, so the Judge can say how long a certificate has left or has # been expired without looking at a clock of its own. GatheredAt = Get-Date Browser = $browser NoBrowser = [bool]($targets.Count -and -not $browser) TimeLimitSeconds = [int]$limit # The browser policies as the Browser Check read them, copied as rows: which proxy # they force is the Judge's to say. Absent where that Check did not run. BrowserObserved = $null -ne $browsers BrowserPolicies = Get-DataCollection $browsers 'Policies' Sites = $sites Prefixes = @(if ($addresses.Count) { Get-OwnPrefix }) Routes = @($addresses | ForEach-Object { Get-AddressRoute -Address $_ }) } | Add-NetworkProxyObserved -Network $network } function ConvertTo-WebsiteFinding { [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()]$Data, [hashtable]$Parameters = @{} ) $entries = @(@(Get-Parameter $Parameters 'Sites' @()) | Where-Object { $null -ne $_ }) if (-not $entries.Count) { return New-Finding -Category Website -Check (Get-Text 'Check.Website.Sites') -Severity INFO ` -Value (Get-Text 'Value.Website.NoneConfigured') -Hint (Get-Text 'Hint.Website.NoneConfigured') } $observations = Get-DataCollection $Data 'Sites' $limit = ConvertTo-Number (Get-DataProperty $Data 'TimeLimitSeconds') if ($null -eq $limit) { $limit = $script:WebsiteTimeLimitSeconds } foreach ($target in @($entries | ForEach-Object { ConvertTo-WebsiteTarget -Entry $_ })) { $check = (Get-Text 'Check.Website.Site') -f $target.Label $findings = @( if ($target.Problem) { New-Finding -Category Website -Check $check -Severity INFO ` -Value ((Get-Text 'Value.Website.InvalidEntry') -f $target.Url) -Hint (Get-Text 'Hint.Website.InvalidEntry') } elseif ([bool](Get-DataProperty $Data 'NoBrowser')) { # The Check could not run, and says so rather than looking like a clean result. New-Finding -Category Website -Check $check -Severity INFO ` -Value (Get-Text 'Value.Website.NoBrowser') -Hint (Get-Text 'Hint.Website.NoBrowser') } else { $observation = @($observations | Where-Object { "$(Get-DataProperty $_ 'Key')" -eq $target.Key }) | Select-Object -First 1 if (-not $observation) { New-UnavailableFinding -Category Website -Check $check -Hint (Get-Text 'Hint.Website.NotRequested') } else { $verdict = New-WebsiteVerdict -Check $check -Target $target -Observation $observation -LimitSeconds $limit -Parameters $Parameters -At (Get-DataProperty $Data 'GatheredAt') $verdict $timing = New-WebsiteTimingFinding -Target $target -Observation $observation -Parameters $Parameters $timing New-WebsiteCertificateFinding -Target $target -Observation $observation -Parameters $Parameters -At (Get-DataProperty $Data 'GatheredAt') # Where the page loaded without fault, what this Check could not see: the # User's own session. A problem that only happens there is still possible. if ($verdict -and $verdict.Severity -eq 'OK') { New-Finding -Category Website -Check ((Get-Text 'Check.Website.OwnSession') -f $target.Label) -Severity INFO ` -Value (Get-Text 'Value.Website.OwnSession') -Hint (Get-Text 'Hint.Website.NetExport') } $others = New-WebsiteOtherHostFinding -Target $target -Observation $observation $others New-WebsiteProxyFinding -Target $target -Observation $observation -Data $Data -Parameters $Parameters New-WebsiteAftermath -Target $target -Observation $observation -LimitSeconds $limit # And each step of the page at each host, for the waterfall, each naming # the Finding that judges it. ADR-0009. ConvertTo-WebsiteMeasurement -Target $target -Observation $observation -Data $Data -Parameters $Parameters ` -Verdict $verdict -Timing $timing -OtherHosts $others } } ) # The application the site belongs to, where its entry names one: the Run keeps it # on the Finding (Set-CheckOrigin), and the Report shows it under that application. foreach ($finding in $findings) { if ($target.App) { $finding.App = $target.App } $finding } } } function New-WebsiteVerdict { <# .SYNOPSIS The one Finding for one site: did the page load, and if not, why. Pure. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)][string]$Check, [Parameter(Mandatory)]$Target, [Parameter(Mandatory)]$Observation, [double]$LimitSeconds = $script:WebsiteTimeLimitSeconds, [hashtable]$Parameters = @{}, # When the page was read; how long a certificate has been expired is counted from it. [AllowNull()]$At ) # Where a page that was sent on ended at a sign-in page: in its host or its path, a # word sign-in pages are called by. A rule of thumb; a Customer's own sign-in page # may be called otherwise, and a Definition can say how. $loginPattern = Get-Parameter $Parameters 'LoginPattern' '(?i)(^|[./_-])(login|logon|signin|sign-in|anmeld\w*|auth|authorize|sso|adfs|oauth2?|saml\w*)([./?_-]|\z)' $page = Get-DataProperty $Observation 'Page' $timedOut = [bool](Get-DataProperty $Observation 'TimedOut') $started = Get-DataProperty $Observation 'Error' $problem = "$(Get-DataProperty $page 'Problem')" if ($started) { return New-Finding -Category Website -Check $Check -Severity INFO ` -Value ((Get-Text 'Value.Website.NotStarted') -f (Get-DataProperty $Observation 'Browser'), $started) ` -Hint (Get-Text 'Hint.Website.NotStarted') } if (-not $page -or $problem -eq 'Unreadable') { return New-Finding -Category Website -Check $Check -Severity INFO ` -Value (Get-Text 'Value.Website.Unreadable') -Hint (Get-Text 'Hint.Website.Unreadable') } if ($problem -eq 'NoPage') { # Within the time limit the browser did not even get to ask: the page is what it # hung on, or something before it - the proxy's configuration, for one. if ($timedOut) { return New-Finding -Category Website -Check $Check -Severity FAIL ` -Value ((Get-Text 'Value.Website.NothingWithin') -f $LimitSeconds) -Hint (Get-Text 'Hint.Website.TimedOut') } # The browser's exit code has no list of meanings; what it means here is that the # browser ended without asking for the page, which the sentence says beside it. return New-Finding -Category Website -Check $Check -Severity INFO ` -Value ((Get-Text 'Value.Website.NotRequested') -f (Get-DataProperty $Observation 'ExitCode')) ` -Hint (Get-Text 'Hint.Website.NotRequested') } $failure = Get-DataProperty $page 'Error' if ($failure) { $final = ConvertTo-WebsiteHostName "$(Get-DataProperty $page 'FinalUrl')" $value = (Get-Text 'Value.Website.NotLoaded') -f (Format-WebsiteNetError $failure), $final # A certificate the browser would not take: who issued it says whose it is. A # firewall that decrypts and re-signs is recognised by its own name here. if ((Get-WebsiteNetErrorKind $failure) -eq 'Certificate') { $facts = @((Get-DataCollection $page 'Hosts') | Where-Object { "$(Get-DataProperty $_ 'Host')" -eq $final }) | Select-Object -First 1 $certificate = Get-DataProperty $facts 'Certificate' $issuer = "$(Get-DataProperty $certificate 'Issuer')" if ($issuer) { $value = (Get-Text 'Value.Website.NotLoadedCertificate') -f (Format-WebsiteNetError $failure), $final, (Get-HttpsEndpointIssuerName $issuer) } # And what the certificate says of itself: an expired one since when, one for # another name for which names. That is what the Technician tells the operator. $value += Format-WebsiteCertificateFacts -Certificate $certificate -At $At } return New-Finding -Category Website -Check $Check -Severity FAIL -Value $value -Hint (Get-WebsiteNetErrorHint $failure) } $status = ConvertTo-Number (Get-DataProperty $page 'Status') if ($null -eq $status) { if ($timedOut) { return New-Finding -Category Website -Check $Check -Severity FAIL ` -Value ((Get-Text 'Value.Website.NoAnswerWithin') -f $LimitSeconds) -Hint (Get-Text 'Hint.Website.TimedOut') } return New-Finding -Category Website -Check $Check -Severity INFO ` -Value (Get-Text 'Value.Website.Unfinished') -Hint (Get-Text 'Hint.Website.NotRequested') } $expected = @($Target.ExpectStatus) $wanted = @($expected | ForEach-Object { if ($_.From -eq $_.To) { "$($_.From)" } else { '{0}-{1}' -f $_.From, $_.To } }) -join ', ' # Windows sign-in: Negotiate (Kerberos, NTLM inside it) and NTLM. What the site asked # for is said either way; that it ended at 401 is the sign-in failing. $signIn = Get-DataProperty $page 'SignIn' $windows = @((Get-DataCollection $signIn 'Schemes') | Where-Object { "$_" -match '^(?i)(negotiate|ntlm|kerberos)$' }) $answered = @($expected | Where-Object { $status -ge $_.From -and $status -le $_.To }).Count -gt 0 if (-not $answered -and $status -eq 401 -and $windows.Count) { $hint = Get-Text 'Hint.Website.SignInRejected' if ((Get-DataProperty $signIn 'DefaultCredentials') -eq $false) { $hint = Get-Text 'Hint.Website.SignInNotSent' } return New-Finding -Category Website -Check $Check -Severity FAIL ` -Value ((Get-Text 'Value.Website.SignInFailed') -f ($windows -join ', ')) -Hint $hint } if (-not $answered) { return New-Finding -Category Website -Check $Check -Severity FAIL ` -Value ((Get-Text 'Value.Website.UnexpectedStatus') -f (Format-HttpStatus ([int]$status)), $wanted) ` -Hint (Get-Text 'Hint.Website.UnexpectedStatus') } $requests = (Get-DataCollection $page 'Requests').Count $hosts = @((Get-DataCollection $page 'Hosts') | Where-Object { (Get-WebsiteHostOutcome $_).Needed }).Count $finalUrl = "$(Get-DataProperty $page 'FinalUrl')" $sentOn = $finalUrl -and $finalUrl -ne "$(Get-DataProperty $page 'Url')" $atLogin = $false if ($sentOn -and $loginPattern) { $u = $null if ([uri]::TryCreate($finalUrl, [UriKind]::Absolute, [ref]$u)) { try { $atLogin = ($u.Host + $u.AbsolutePath) -match $loginPattern } catch { $atLogin = $false } } } # Not broken: the page works up to the sign-in, which Gutcheck does not do. Said so, # so that nobody reads "ends at a login page" as "does not load". if ($atLogin) { return New-Finding -Category Website -Check $Check -Severity INFO ` -Value ((Get-Text 'Value.Website.LoginPage') -f (Format-HttpStatus ([int]$status)), $finalUrl) -Hint (Get-Text 'Hint.Website.LoginPage') } $value = (Get-Text 'Value.Website.Loaded') -f (Format-HttpStatus ([int]$status)), $requests, $hosts if ($sentOn) { $value = (Get-Text 'Value.Website.LoadedAt') -f (Format-HttpStatus ([int]$status)), $requests, $hosts, $finalUrl } if ($windows.Count -and $status -ne 401) { $value += (Get-Text 'Value.Website.SignedIn') -f ($windows -join ', ') } New-Finding -Category Website -Check $Check -Severity OK -Value $value } function Format-WebsiteCertificateFacts { <# .SYNOPSIS A certificate's names and the time it is valid, and how far $At lies outside that time, as the end of a Value. Nothing where the certificate says nothing. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Certificate, [AllowNull()]$At) $from = Get-DataProperty $Certificate 'NotBefore' $until = Get-DataProperty $Certificate 'NotAfter' if (-not $from -or -not $until) { return '' } # The first few names; a certificate for a hundred names is not read for the hundredth. $names = @((Get-DataCollection $Certificate 'Names') | Where-Object { $_ }) $named = (@($names | Select-Object -First 4)) -join ', ' if ($names.Count -gt 4) { $named = (Get-Text 'Value.Website.CertificateMoreNames') -f $named, ($names.Count - 4) } $text = (Get-Text 'Value.Website.CertificateFacts') -f $named, [datetime]$from, [datetime]$until if ($At) { if ([datetime]$until -lt [datetime]$At) { $text += (Get-Text 'Value.Website.CertificateExpiredSince') -f [math]::Floor(([datetime]$At - [datetime]$until).TotalDays) } elseif ([datetime]$from -gt [datetime]$At) { $text += (Get-Text 'Value.Website.CertificateNotYetValid') -f [math]::Floor(([datetime]$from - [datetime]$At).TotalDays) } } $text } function New-WebsiteCertificateFinding { <# .SYNOPSIS A warning where the certificate of a page that loaded runs out soon. Pure. .DESCRIPTION Fourteen days by default, and not the sixty the HttpsEndpoint Kind warns at: a website's certificate is often one that renews itself thirty days before it runs out (Let's Encrypt and the like), and one that has fourteen days left is one whose renewal failed. A rule of thumb; a Definition can say otherwise. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)]$Target, [Parameter(Mandatory)]$Observation, [hashtable]$Parameters = @{}, [AllowNull()]$At ) $warnDays = ConvertTo-Number (Get-Parameter $Parameters 'CertificateWarnDays' 14) $page = Get-DataProperty $Observation 'Page' if (-not $At -or -not $page -or (Get-DataProperty $page 'Error')) { return } $final = ConvertTo-WebsiteHostName "$(Get-DataProperty $page 'FinalUrl')" $facts = @((Get-DataCollection $page 'Hosts') | Where-Object { "$(Get-DataProperty $_ 'Host')" -eq $final }) | Select-Object -First 1 $certificate = Get-DataProperty $facts 'Certificate' $until = Get-DataProperty $certificate 'NotAfter' if (-not $until) { return } $left = [math]::Floor(([datetime]$until - [datetime]$At).TotalDays) if ($left -lt 0 -or $left -ge $warnDays) { return } New-Finding -Category Website -Check ((Get-Text 'Check.Website.Certificate') -f $final) -Severity WARN ` -Value ((Get-Text 'Value.Website.CertificateExpiresSoon') -f $left, [datetime]$until, (Get-HttpsEndpointIssuerName "$(Get-DataProperty $certificate 'Issuer')")) ` -Meaning (Get-Text 'Meaning.Website.CertificateExpiresSoon') -Hint (Get-Text 'Hint.Website.CertificateExpiresSoon') } function New-WebsiteOtherHostFinding { <# .SYNOPSIS The hosts beyond the page's own where requests failed, beside a page that answered: a WARN naming them. Nothing where there are none. Pure. .DESCRIPTION "The page loads but stays white" is a page whose own host answers and whose CDN, API or sign-in host does not. Only beside a page that answered: where the page itself did not, that is the Finding, and the rest follows from it. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)]$Target, [Parameter(Mandatory)]$Observation) $page = Get-DataProperty $Observation 'Page' if ($null -eq (ConvertTo-Number (Get-DataProperty $page 'Status')) -or (Get-DataProperty $page 'Error')) { return } $own = @((Get-DataCollection $page 'PageHosts') | ForEach-Object { "$_" }) # Failed, or never answered: a CDN that hangs holds a page up as surely as one that # refuses. Each host named is a part of the Finding, by the name its Measurements carry # (ADR-0009): a host beside it where nothing failed names no Finding at all. $parts = [ordered]@{} $named = @(foreach ($h in (Get-DataCollection $page 'Hosts')) { $name = "$(Get-DataProperty $h 'Host')" if ($own -contains $name) { continue } $outcome = Get-WebsiteHostOutcome $h $requests = Get-DataProperty $h 'Requests' if (($outcome.Failed -or $outcome.Unfinished) -and $name.Trim()) { $parts[$name] = 'WARN' } if ($outcome.Failed) { $said = (Get-Text 'Value.Website.OtherHost') -f $name, $outcome.Failed, $requests, (@($outcome.Errors | ForEach-Object { Format-WebsiteNetError $_ }) -join ', ') # A certificate the browser would not take there: who issued it says whose it is. $issuer = "$(Get-DataProperty (Get-DataProperty $h 'Certificate') 'Issuer')" if ($issuer -and @($outcome.Errors | Where-Object { (Get-WebsiteNetErrorKind $_) -eq 'Certificate' }).Count) { $said = (Get-Text 'Value.Website.OtherHostIssuer') -f $said, (Get-HttpsEndpointIssuerName $issuer) } $said } if ($outcome.Unfinished) { (Get-Text 'Value.Website.OtherHostOpen') -f $name, $outcome.Unfinished, $requests } }) -join '; ' if (-not $named) { return } New-Finding -Category Website -Check ((Get-Text 'Check.Website.OtherHosts') -f $Target.Label) -Severity WARN ` -Value ((Get-Text 'Value.Website.OtherHostsFailed') -f $named) -Hint (Get-Text 'Hint.Website.OtherHosts') -Subjects $parts } function New-WebsiteProxyFinding { <# .SYNOPSIS The proxy the page went through, how Edge found it, and - where the Network and Browser Checks ran - the User's proxy, the WinHTTP proxy and the one a policy forces beside it. Nothing where the net log shows no proxy configuration. Pure. .DESCRIPTION WARN for what is known to cost a page its time or its answer: finding the proxy took long (ProxyDetectWarnMs, 2 s - a rule of thumb: the classic WPAD timeout that makes every first page slow is well above it, a working detection well below), a PAC file that could not be fetched, a proxy that asked for a sign-in. INFO where Edge did not take its proxy from Windows: that is a policy, and the answer to "it works in one program and not the other". OK otherwise. Where neither Check ran, the Finding states what Edge used and compares nothing. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)]$Target, [Parameter(Mandatory)]$Observation, [AllowNull()]$Data, [hashtable]$Parameters = @{}) $warnMs = ConvertTo-Number (Get-Parameter $Parameters 'ProxyDetectWarnMs' 2000) $proxy = Get-DataProperty (Get-DataProperty $Observation 'Page') 'Proxy' if (-not $proxy) { return } $mode = "$(Get-DataProperty $proxy 'Mode')" # In the words the Browsers Check uses for the proxy a policy sets: the same modes. if ($mode -notin 'Detect', 'Script', 'Server') { $mode = 'Direct' } $edge = Get-BrowserPolicyProxyText -Proxy ([pscustomobject]@{ Mode = $mode; Server = "$(Get-DataProperty $proxy 'Server')"; Script = "$(Get-DataProperty $proxy 'PacUrl')" }) $fromSystem = Get-DataProperty $proxy 'FromSystem' if ($fromSystem -eq $true) { $edge = (Get-Text 'Value.Website.ProxyFrom.System') -f $edge } elseif ($fromSystem -eq $false) { $edge = (Get-Text 'Value.Website.ProxyFrom.Policy') -f $edge } $used = "$(Get-DataProperty $proxy 'Used')".Trim() if ($used) { $first = ($used -split ';')[0].Trim() $shown = $first -replace '^(?i)(PROXY|HTTPS|SOCKS5?|QUIC)\s+', '' if ($first -match '^(?i)DIRECT$') { $shown = Get-BrowserProxyModeText -Mode 'Direct' } $edge = (Get-Text 'Value.Website.ProxyUsed') -f $edge, $shown } $parts = @($edge) # The proxy a policy forces on the browser that loaded the page, from the policies the # Browsers Check read. $policy = $null $browserName = "$(Get-DataProperty (Get-DataProperty $Data 'Browser') 'Name')" if ($browserName) { $policy = Get-BrowserPolicyProxy -Data ([pscustomobject]@{ Policies = (Get-DataCollection $Data 'BrowserPolicies') }) ` -Browser ([pscustomobject]@{ Browser = $browserName; Engine = 'Chromium' }) } $network = [bool](Get-DataProperty $Data 'NetworkObserved') if ($network) { $parts += (Get-Text 'Value.Website.Proxy.User') -f (Get-UserProxyDescription (Get-DataProperty $Data 'UserProxy')) $view = @(Get-WinHttpProxyView -Data $Data)[0] $parts += (Get-Text 'Value.Website.Proxy.WinHttp') -f (Get-WinHttpProxyDescription $view.Parsed) } if ($policy) { $parts += (Get-Text 'Value.Website.Proxy.Policy') -f (Get-BrowserPolicyProxyText -Proxy $policy) } elseif (-not [bool](Get-DataProperty $Data 'BrowserObserved')) { $parts += Get-Text 'Value.Website.Proxy.PolicyNotCompared' } $value = $parts -join '; ' $check = (Get-Text 'Check.Website.Proxy') -f $Target.Label if ([bool](Get-DataProperty $proxy 'SignInAsked')) { return New-Finding -Category Website -Check $check -Severity WARN ` -Value ((Get-Text 'Value.Website.ProxySignIn') -f $value) -Hint (Get-Text 'Hint.Website.ProxySignIn') } $pacFailed = @((Get-DataCollection $proxy 'PacFetches') | Where-Object { (Get-DataProperty $_ 'Error') -and -not (Get-DataProperty $_ 'Wpad') }) | Select-Object -First 1 if ($pacFailed) { return New-Finding -Category Website -Check $check -Severity WARN ` -Value ((Get-Text 'Value.Website.PacFailed') -f $value, (Format-WebsiteNetError (Get-DataProperty $pacFailed 'Error'))) ` -Hint (Get-Text 'Hint.Website.PacFailed') } $took = ConvertTo-Number (Get-DataProperty $proxy 'ResolveMs') if ($mode -in 'Detect', 'Script' -and $null -ne $took -and $null -ne $warnMs -and $took -gt $warnMs) { return New-Finding -Category Website -Check $check -Severity WARN ` -Value ((Get-Text 'Value.Website.ProxySlow') -f $value, ($took / 1000)) -Hint (Get-Text 'Hint.Website.ProxySlow') } $differs = $fromSystem -eq $false if ($policy -and $network -and -not (Test-BrowserProxySame -Proxy $policy -UserProxy (Get-DataProperty $Data 'UserProxy'))) { $differs = $true } if ($differs) { return New-Finding -Category Website -Check $check -Severity INFO -Value $value ` -Meaning (Get-Text 'Meaning.Website.ProxyDiffers') -Hint (Get-Text 'Hint.Website.ProxyDiffers') } New-Finding -Category Website -Check $check -Severity OK -Value $value } function New-WebsiteTimingFinding { <# .SYNOPSIS Where the time of a page that answered went: name resolution, connection and TLS at its own host, the wait for its first byte, and the whole load. Pure. .DESCRIPTION Each against a parameter of the Check Definition. The defaults are rules of thumb, not anybody's documented limit: a name lookup, a connection or a TLS handshake within a network takes milliseconds, and one that takes a second is felt on every page; a server that has not begun to answer after three seconds is what a User calls "hangs"; a page that needs ten seconds in all is what they call slow. A step the page did not take - a connection it already had open - is left out rather than counted as quick. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)]$Target, [Parameter(Mandatory)]$Observation, [hashtable]$Parameters = @{}) $page = Get-DataProperty $Observation 'Page' if ($null -eq (ConvertTo-Number (Get-DataProperty $page 'Status')) -or (Get-DataProperty $page 'Error')) { return } $timings = Get-DataProperty $page 'Timings' if (-not $timings) { return } $steps = @( @{ Step = 'Dns'; Ms = ConvertTo-Number (Get-DataProperty $timings 'DnsMs'); Limit = ConvertTo-Number (Get-Parameter $Parameters 'DnsWarnMs' 1000) } @{ Step = 'Connect'; Ms = ConvertTo-Number (Get-DataProperty $timings 'ConnectMs'); Limit = ConvertTo-Number (Get-Parameter $Parameters 'ConnectWarnMs' 1000) } @{ Step = 'Tls'; Ms = ConvertTo-Number (Get-DataProperty $timings 'TlsMs'); Limit = ConvertTo-Number (Get-Parameter $Parameters 'TlsWarnMs' 1000) } @{ Step = 'FirstByte'; Ms = ConvertTo-Number (Get-DataProperty $timings 'FirstByteMs'); Limit = ConvertTo-Number (Get-Parameter $Parameters 'FirstByteWarnMs' 3000) } @{ Step = 'Load'; Ms = ConvertTo-Number (Get-DataProperty $timings 'LoadMs'); Limit = ConvertTo-Number (Get-Parameter $Parameters 'LoadWarnMs' 10000) } ) $shown = { param($Ms) if ($null -eq $Ms) { '-' } else { $Ms } } $load = $steps[4].Ms $loadSeconds = $(if ($null -eq $load) { '-' } else { $load / 1000 }) if ($null -eq $steps[0].Ms -and $null -eq $steps[1].Ms -and $null -eq $steps[2].Ms) { $value = (Get-Text 'Value.Website.TimingsReused') -f (& $shown $steps[3].Ms), $loadSeconds } elseif ($null -eq $steps[0].Ms -and [bool](Get-DataProperty $timings 'DnsCached')) { # A name the browser had in its cache took no time to look up, and is no 0 ms # reading of the DNS server: it says where the answer came from instead. $value = (Get-Text 'Value.Website.TimingsCached') -f (& $shown $steps[1].Ms), (& $shown $steps[2].Ms), (& $shown $steps[3].Ms), $loadSeconds } else { $value = (Get-Text 'Value.Website.Timings') -f (& $shown $steps[0].Ms), (& $shown $steps[1].Ms), (& $shown $steps[2].Ms), (& $shown $steps[3].Ms), $loadSeconds } $slow = @($steps | Where-Object { $null -ne $_.Ms -and $null -ne $_.Limit -and $_.Ms -gt $_.Limit }) $check = (Get-Text 'Check.Website.Timings') -f $Target.Label if (-not $slow.Count) { return New-Finding -Category Website -Check $check -Severity OK -Value $value } $named = @(foreach ($one in $slow) { switch ($one.Step) { 'Dns' { Get-Text 'Value.Website.Slow.Dns' } 'Connect' { Get-Text 'Value.Website.Slow.Connect' } 'Tls' { Get-Text 'Value.Website.Slow.Tls' } 'FirstByte' { Get-Text 'Value.Website.Slow.FirstByte' } 'Load' { Get-Text 'Value.Website.Slow.Load' } } }) -join ', ' # The first slow step in the order the page takes them: what comes after it waited # on it. $hint = switch ($slow[0].Step) { 'Dns' { Get-Text 'Hint.Website.Slow.Dns' } 'Connect' { Get-Text 'Hint.Website.Slow.Connect' } 'Tls' { Get-Text 'Hint.Website.Slow.Tls' } 'FirstByte' { Get-Text 'Hint.Website.Slow.FirstByte' } 'Load' { Get-Text 'Hint.Website.Slow.Load' } } New-Finding -Category Website -Check $check -Severity WARN -Value ((Get-Text 'Value.Website.SlowSteps') -f $value, $named) -Hint $hint } # Each step of a page at a host as the net log parse names it, and the Metric it is drawn # as. The span of a host's requests is drawn as Response: from the first of them sent to # the last of them done. $script:WebsiteStepMetric = [ordered]@{ Dns = 'Dns' Connect = 'TcpConnect' Tls = 'Tls' FirstByte = 'FirstByte' Response = 'Response' } function ConvertTo-WebsiteStepMeasurement { <# .SYNOPSIS One step of a page as a Measurement: measured, from the cache, failed with how long it took to, or not measured where the net log does not show it. Pure. .DESCRIPTION A step the browser was stopped in got no answer, and has no number: its end is not known. One that failed with an error the browser caused itself - an abort as it closed the page - was not measured rather than failed: no Finding counts it either. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Step, [Parameter(Mandatory)][hashtable]$Common, [Parameter(Mandatory)][string]$Metric) $state = "$(Get-DataProperty $Step 'State')" $start = Get-DataProperty $Step 'StartMs' $failure = Get-DataProperty $Step 'Error' switch ($state) { 'Measured' { return New-Measurement @Common -Metric $Metric -StartMs $start -Value (Get-DataProperty $Step 'Ms') } 'Cached' { return New-Measurement @Common -Metric $Metric -StartMs $start -State Cached } 'Open' { return New-Measurement @Common -Metric $Metric -StartMs $start -State Failed -ErrorText (Get-Text 'Measurement.Error.CutOff') } 'Failed' { if ((Get-WebsiteNetErrorKind $failure) -eq 'Dropped') { return New-Measurement @Common -Metric $Metric -State NotMeasured } return New-Measurement @Common -Metric $Metric -StartMs $start -State Failed -Value (Get-DataProperty $Step 'Ms') ` -ErrorText (Format-WebsiteNetError $failure) } } New-Measurement @Common -Metric $Metric -State NotMeasured } function ConvertTo-WebsiteMeasurement { <# .SYNOPSIS What the page of one site measured, for the waterfall: the page's first byte and whole load (Role Website), then each step at each host it needed (Role WebsiteHost), on one time axis from the page's start. Pure. .DESCRIPTION The page's own come first and its hosts after, each host's steps in the order a connection takes them. Each names the site as its Group, the address as the Definition gives it: the waterfall draws one card per Group. StartMs is counted from when the browser began on the page; a name looked up before the page's own request starts at 0 and the request a little after. Each names the Finding that judges it: at the page's own host the timing Finding (the verdict where the page did not load), at a host the page was sent on through the verdict, at another host the Finding that names the hosts where requests failed - or none where nothing failed there, and a view draws it grey. The Zone is the host's address's, by the machine's own subnets and the route the Gatherer read to it. A host reached through a proxy was not resolved or connected to by this machine: it has no address, and every step says which proxy it went through. .PARAMETER Verdict The Finding of whether the page loaded. .PARAMETER Timing The Finding of where the page's time went, where there is one. .PARAMETER OtherHosts The Finding naming the hosts beyond the page's own where requests failed. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)]$Target, [Parameter(Mandatory)]$Observation, [AllowNull()]$Data, [hashtable]$Parameters = @{}, [AllowNull()]$Verdict, [AllowNull()]$Timing, [AllowNull()]$OtherHosts ) $page = Get-DataProperty $Observation 'Page' if (-not $page -or "$(Get-DataProperty $page 'Problem')") { return } $vpnPattern = Get-Parameter $Parameters 'VpnAdapterPattern' $script:MeasurementVpnAdapterPattern $pageFinding = $Timing if (-not $pageFinding) { $pageFinding = $Verdict } $hosts = @((Get-DataCollection $page 'Hosts') | Where-Object { (Get-WebsiteHostOutcome $_).Needed }) $finalHost = ConvertTo-WebsiteHostName "$(Get-DataProperty $page 'FinalUrl')" $own = @((Get-DataCollection $page 'PageHosts') | ForEach-Object { "$_" }) # Where a host was reached: the address it was connected to, else the first it # resolved to; through a proxy, none of its own. $whereOf = { param($HostFacts) $address = ''; $port = $null if (-not "$(Get-DataProperty $HostFacts 'Proxy')") { $remote = "$(Get-DataProperty $HostFacts 'RemoteAddress')" if ($remote -match '^\[?(?<address>[^\]]+?)\]?:(?<port>\d+)$') { $address = $Matches['address']; $port = [int]$Matches['port'] } elseif ($remote) { $address = $remote } else { $address = "$(@((Get-DataCollection $HostFacts 'Addresses'))[0])" } } [pscustomobject]@{ Address = $address; Port = $port } } # The page itself: its first byte and its whole load, at the host it ended at. $timings = Get-DataProperty $page 'Timings' $finalFacts = @($hosts | Where-Object { "$(Get-DataProperty $_ 'Host')" -eq $finalHost }) | Select-Object -First 1 $at = & $whereOf $finalFacts $common = @{ Subject = $Target.Url; Address = $at.Address; Role = 'Website'; Samples = 1; Port = $at.Port; Group = $Target.Url Zone = (Get-GatheredAddressZone -Data $Data -Address $at.Address -VpnPattern $vpnPattern) Proxy = "$(Get-DataProperty $finalFacts 'Proxy')"; Finding = $pageFinding } $firstByte = ConvertTo-Number (Get-DataProperty $timings 'FirstByteMs') if ($null -eq $firstByte) { New-Measurement @common -Metric FirstByte -State NotMeasured } else { New-Measurement @common -Metric FirstByte -StartMs (Get-DataProperty $timings 'FirstByteStartMs') -Value $firstByte } $failure = Get-DataProperty $page 'Error' $pageStart = Get-DataProperty $timings 'StartMs' $load = ConvertTo-Number (Get-DataProperty $timings 'LoadMs') if ($failure) { New-Measurement @common -Metric Response -StartMs $pageStart -State Failed -Value (Get-DataProperty $page 'ElapsedMs') ` -ErrorText (Format-WebsiteNetError $failure) } elseif (-not [bool](Get-DataProperty $page 'Finished')) { New-Measurement @common -Metric Response -StartMs $pageStart -State Failed -ErrorText (Get-Text 'Measurement.Error.CutOff') } elseif ($null -eq $load) { New-Measurement @common -Metric Response -State NotMeasured } else { New-Measurement @common -Metric Response -StartMs $pageStart -Value $load } # Each host the page needed, each step of it. foreach ($facts in $hosts) { $name = "$(Get-DataProperty $facts 'Host')" $belongs = $null if ($name -eq $finalHost) { $belongs = $pageFinding } elseif ($own -contains $name) { $belongs = $Verdict } else { $outcome = Get-WebsiteHostOutcome $facts if ($outcome.Failed -or $outcome.Unfinished) { $belongs = $OtherHosts } } $at = & $whereOf $facts $common = @{ Subject = $name; Address = $at.Address; Role = 'WebsiteHost'; Samples = 1; Port = $at.Port; Group = $Target.Url Zone = (Get-GatheredAddressZone -Data $Data -Address $at.Address -VpnPattern $vpnPattern) Proxy = "$(Get-DataProperty $facts 'Proxy')"; Finding = $belongs } $steps = Get-DataCollection $facts 'Steps' foreach ($stepName in $script:WebsiteStepMetric.Keys) { $step = @($steps | Where-Object { "$(Get-DataProperty $_ 'Step')" -eq $stepName }) | Select-Object -First 1 ConvertTo-WebsiteStepMeasurement -Step $step -Common $common -Metric $script:WebsiteStepMetric[$stepName] } } } function New-WebsiteAftermath { <# .SYNOPSIS What the Check itself left of a site: a load it cut off, the net log it kept, a profile it could not delete. Pure. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)]$Target, [Parameter(Mandatory)]$Observation, [double]$LimitSeconds = $script:WebsiteTimeLimitSeconds ) $page = Get-DataProperty $Observation 'Page' # Where the page had answered and was still loading more when it was stopped. Where it # had not, the verdict already says it was cut off. if ([bool](Get-DataProperty $Observation 'TimedOut') -and $null -ne (ConvertTo-Number (Get-DataProperty $page 'Status'))) { New-Finding -Category Website -Check ((Get-Text 'Check.Website.CutOff') -f $Target.Label) -Severity INFO ` -Value ((Get-Text 'Value.Website.CutOff') -f $LimitSeconds) -Hint (Get-Text 'Hint.Website.CutOff') } $log = "$(Get-DataProperty $Observation 'NetLog')" if ($log) { New-Finding -Category Website -Check ((Get-Text 'Check.Website.NetLog') -f $Target.Label) -Severity INFO ` -Value ((Get-Text 'Value.Website.NetLog') -f $log) -Meaning (Get-Text 'Meaning.Website.NetLog') ` -Hint (Get-Text 'Hint.Website.NetLog') } $left = "$(Get-DataProperty $Observation 'ProfileLeft')" if ($left) { New-Finding -Category Website -Check ((Get-Text 'Check.Website.Profile') -f $Target.Label) -Severity INFO ` -Value ((Get-Text 'Value.Website.ProfileLeft') -f $left) -Hint (Get-Text 'Hint.Website.ProfileLeft') } } # The net errors this Kind knows by name: what each means in words, and what kind of fault # it is. The one place a net error is recognised by its name - its words, its Hint, whether # it is a failure at all are all asked of this table through Get-WebsiteNetErrorKind and # Get-WebsiteNetErrorText. By name, because the names are stable and the numbers are the # document's. Kind is Dns, Proxy, Certificate, Connection, Other, or Dropped: the browser's # own doing, no failure of the site. Chromium aborts what is still in flight when the page # is closed, which the browser does once it has loaded it; and it asks its cache for a font # or a preload before it asks the network, which it then does in a request of its own # (ERR_CACHE_MISS, seen twice for the same address on 2026-10-14). Text is asked for when # the words are needed. $script:WebsiteNetError = @{ 'ERR_NAME_NOT_RESOLVED' = @{ Kind = 'Dns'; Text = { Get-Text 'Value.Website.NetError.NameNotResolved' } } 'ERR_NAME_RESOLUTION_FAILED' = @{ Kind = 'Dns'; Text = { Get-Text 'Value.Website.NetError.NameResolutionFailed' } } 'ERR_CONNECTION_REFUSED' = @{ Kind = 'Connection'; Text = { Get-Text 'Value.Website.NetError.ConnectionRefused' } } 'ERR_CONNECTION_RESET' = @{ Kind = 'Connection'; Text = { Get-Text 'Value.Website.NetError.ConnectionReset' } } 'ERR_CONNECTION_CLOSED' = @{ Kind = 'Connection'; Text = { Get-Text 'Value.Website.NetError.ConnectionClosed' } } 'ERR_CONNECTION_TIMED_OUT' = @{ Kind = 'Connection'; Text = { Get-Text 'Value.Website.NetError.ConnectionTimedOut' } } 'ERR_TIMED_OUT' = @{ Kind = 'Other'; Text = { Get-Text 'Value.Website.NetError.TimedOut' } } 'ERR_CONNECTION_FAILED' = @{ Kind = 'Connection'; Text = { Get-Text 'Value.Website.NetError.ConnectionFailed' } } 'ERR_ADDRESS_UNREACHABLE' = @{ Kind = 'Connection'; Text = { Get-Text 'Value.Website.NetError.AddressUnreachable' } } 'ERR_INTERNET_DISCONNECTED' = @{ Kind = 'Connection'; Text = { Get-Text 'Value.Website.NetError.InternetDisconnected' } } 'ERR_CERT_AUTHORITY_INVALID' = @{ Kind = 'Certificate'; Text = { Get-Text 'Value.Website.NetError.CertAuthorityInvalid' } } 'ERR_CERT_COMMON_NAME_INVALID' = @{ Kind = 'Certificate'; Text = { Get-Text 'Value.Website.NetError.CertCommonNameInvalid' } } 'ERR_CERT_DATE_INVALID' = @{ Kind = 'Certificate'; Text = { Get-Text 'Value.Website.NetError.CertDateInvalid' } } 'ERR_CERT_REVOKED' = @{ Kind = 'Certificate'; Text = { Get-Text 'Value.Website.NetError.CertRevoked' } } 'ERR_CERT_INVALID' = @{ Kind = 'Certificate'; Text = { Get-Text 'Value.Website.NetError.CertInvalid' } } 'ERR_SSL_PROTOCOL_ERROR' = @{ Kind = 'Certificate'; Text = { Get-Text 'Value.Website.NetError.SslProtocolError' } } 'ERR_PROXY_CONNECTION_FAILED' = @{ Kind = 'Proxy'; Text = { Get-Text 'Value.Website.NetError.ProxyConnectionFailed' } } 'ERR_TUNNEL_CONNECTION_FAILED' = @{ Kind = 'Proxy'; Text = { Get-Text 'Value.Website.NetError.TunnelConnectionFailed' } } 'ERR_MANDATORY_PROXY_CONFIGURATION_FAILED' = @{ Kind = 'Proxy'; Text = { Get-Text 'Value.Website.NetError.ProxyConfigurationFailed' } } 'ERR_TOO_MANY_REDIRECTS' = @{ Kind = 'Other'; Text = { Get-Text 'Value.Website.NetError.TooManyRedirects' } } 'ERR_ABORTED' = @{ Kind = 'Dropped'; Text = { Get-Text 'Value.Website.NetError.Aborted' } } 'ERR_CACHE_MISS' = @{ Kind = 'Dropped' } } function Get-WebsiteNetErrorKind { <# .SYNOPSIS What kind of fault a net error is: Dns, Proxy, Certificate, Connection, Dropped or Other. Pure. .DESCRIPTION From the table where it is there; else from the family its name belongs to (a proxy's, a certificate's or TLS's), else from the range of its number. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$NetError) $name = "$(Get-DataProperty $NetError 'Name')".Trim() if ($name -and $script:WebsiteNetError.ContainsKey($name)) { return $script:WebsiteNetError[$name].Kind } if ($name -match '^ERR_(PROXY_|TUNNEL_|MANDATORY_PROXY_)') { return 'Proxy' } if ($name -match '^ERR_(CERT_|SSL_)') { return 'Certificate' } switch (Get-WebsiteNetErrorRange $NetError) { 'Dns' { return 'Dns' } 'Certificate' { return 'Certificate' } 'Connection' { return 'Connection' } } 'Other' } function Get-WebsiteNetErrorText { <# .SYNOPSIS What a Chromium net error means, in words: the ones of the table each by name, any other by what kind of error its number says it is. Pure. .DESCRIPTION The kinds are Chromium's own ranges (net/base/net_error_list.h): 1-99 the system, 100-199 the connection, 200-299 and 700-799 certificates, 300-399 HTTP, 400-499 the cache, 800-899 name resolution. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$NetError) $name = "$(Get-DataProperty $NetError 'Name')".Trim() if ($name -and $script:WebsiteNetError.ContainsKey($name) -and $script:WebsiteNetError[$name].Text) { return (& $script:WebsiteNetError[$name].Text) } switch (Get-WebsiteNetErrorRange $NetError) { 'System' { return Get-Text 'Value.Website.NetErrorRange.System' } 'Connection' { return Get-Text 'Value.Website.NetErrorRange.Connection' } 'Certificate' { return Get-Text 'Value.Website.NetErrorRange.Certificate' } 'Http' { return Get-Text 'Value.Website.NetErrorRange.Http' } 'Cache' { return Get-Text 'Value.Website.NetErrorRange.Cache' } 'Dns' { return Get-Text 'Value.Website.NetErrorRange.Dns' } } Get-Text 'Value.Website.NetErrorRange.Other' } function Get-WebsiteNetErrorRange { <# .SYNOPSIS Which of Chromium's ranges a net error's number falls in. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$NetError) $code = ConvertTo-Number (Get-DataProperty $NetError 'Code') if ($null -eq $code) { return 'Other' } $n = [math]::Abs($code) if ($n -ge 1 -and $n -le 99) { return 'System' } if ($n -ge 100 -and $n -le 199) { return 'Connection' } if (($n -ge 200 -and $n -le 299) -or ($n -ge 700 -and $n -le 799)) { return 'Certificate' } if ($n -ge 300 -and $n -le 399) { return 'Http' } if ($n -ge 400 -and $n -le 499) { return 'Cache' } if ($n -ge 800 -and $n -le 899) { return 'Dns' } 'Other' } function Format-WebsiteNetError { <# .SYNOPSIS A net error as a Technician reads it: what it means, with its name beside it - or its number where the document gave it no name. Never one of them alone. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$NetError) if ($null -eq $NetError) { return '' } $name = "$(Get-DataProperty $NetError 'Name')".Trim() if (-not $name) { $name = "$(Get-DataProperty $NetError 'Code')".Trim() } (Get-Text 'Value.Website.NetError') -f (Get-WebsiteNetErrorText $NetError), $name } function Get-WebsiteNetErrorHint { <# .SYNOPSIS Where a net error sends the Technician: name resolution, the proxy, the certificate, the connection, or second level. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$NetError) switch (Get-WebsiteNetErrorKind $NetError) { 'Dns' { return Get-Text 'Hint.Website.NotResolved' } 'Proxy' { return Get-Text 'Hint.Website.Proxy' } 'Certificate' { return Get-Text 'Hint.Website.Certificate' } 'Connection' { return Get-Text 'Hint.Website.Connection' } } Get-Text 'Hint.Website.NotLoaded' } function Get-WebsiteHostOutcome { <# .SYNOPSIS What the requests at one host came to, from the errors the net log recorded: how many failed, how many the browser dropped itself, the distinct errors of those that failed, and whether the host was needed at all. Pure. .DESCRIPTION A host the walk reached with no request of its own - an attempt the browser raced and dropped - was not one the page needed. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$HostFacts) $failed = New-Object System.Collections.Generic.List[psobject] $dropped = 0 foreach ($failure in (Get-DataCollection $HostFacts 'Failures')) { if ((Get-WebsiteNetErrorKind $failure) -eq 'Dropped') { $dropped++ } else { $failed.Add($failure) } } [pscustomobject]@{ Needed = (ConvertTo-Number (Get-DataProperty $HostFacts 'Requests')) -gt 0 Failed = $failed.Count Dropped = $dropped Unfinished = [int](ConvertTo-Number (Get-DataProperty $HostFacts 'Unfinished')) Errors = @($failed | Group-Object { "$(Get-DataProperty $_ 'Code')" } | ForEach-Object { $_.Group[0] }) } } function ConvertTo-WebsiteSection { <# .SYNOPSIS One table per site: every host its page needed and what happened at it. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) foreach ($site in (Get-DataCollection $Data 'Sites')) { $hosts = Get-DataCollection (Get-DataProperty $site 'Page') 'Hosts' if (-not $hosts.Count) { continue } $section = New-Section -Title ((Get-Text 'Title.Website.Hosts') -f (Get-DataProperty $site 'Label')) ` -Help (Get-Text 'Help.Website.Hosts') -Row @( foreach ($h in $hosts) { $outcome = Get-WebsiteHostOutcome $h if (-not $outcome.Needed) { continue } $row = [ordered]@{} $row[(Get-Text 'Column.Website.Host')] = Get-DataProperty $h 'Host' $row[(Get-Text 'Column.Website.Requests')] = Get-DataProperty $h 'Requests' $row[(Get-Text 'Column.Website.Failed')] = $outcome.Failed $row[(Get-Text 'Column.Website.Open')] = Get-DataProperty $h 'Unfinished' $row[(Get-Text 'Column.Website.Addresses')] = (Get-DataCollection $h 'Addresses') -join ', ' $row[(Get-Text 'Column.Website.Dns')] = Format-WebsiteStepCell -Done (Get-DataProperty $h 'Resolved') ` -Failure (Get-DataProperty $h 'ResolveError') -Shown (Get-Text 'Value.Website.Resolved') $row[(Get-Text 'Column.Website.Connection')] = Format-WebsiteStepCell -Done (Get-DataProperty $h 'Connected') ` -Failure (Get-DataProperty $h 'ConnectError') -Shown ((Get-Text 'Value.Website.Connected') -f (Get-DataProperty $h 'RemoteAddress')) $row[(Get-Text 'Column.Website.Tls')] = Format-WebsiteStepCell -Done ([bool](Get-DataProperty $h 'Tls')) ` -Failure (Get-DataProperty $h 'TlsError') -Shown "$(Get-DataProperty $h 'Tls')" $row[(Get-Text 'Column.Website.Issuer')] = Get-HttpsEndpointIssuerName "$(Get-DataProperty (Get-DataProperty $h 'Certificate') 'Issuer')" $until = Get-DataProperty (Get-DataProperty $h 'Certificate') 'NotAfter' $row[(Get-Text 'Column.Website.CertificateUntil')] = $(if ($until) { '{0:dd.MM.yyyy}' -f [datetime]$until } else { '' }) $row[(Get-Text 'Column.Website.Errors')] = @($outcome.Errors | ForEach-Object { Format-WebsiteNetError $_ }) -join '; ' [pscustomobject]$row }) if ($section -and "$(Get-DataProperty $site 'App')") { $section.App = "$(Get-DataProperty $site 'App')" } $section } } function Format-WebsiteStepCell { <# .SYNOPSIS One step at a host, for a Section: what it came to, the error it failed with, or nothing where the net log does not say. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Done, [AllowNull()]$Failure, [AllowEmptyString()][string]$Shown) if ($Done -eq $true) { return $Shown } if ($Failure) { return Format-WebsiteNetError $Failure } '' } |