Public/Get-VBHTTPBanner.ps1

function Get-VBHTTPBanner {
<#
.SYNOPSIS
    Grab HTTP page title and Server header from a device's web interface (Layer 6).
 
.DESCRIPTION
    Tries ports in order: 80 -> 8080 -> 443 -> 8443. Stops at the first successful
    response. Extracts the HTML <title> element and the Server: response header.
 
    PS 6+ uses Invoke-WebRequest -SkipCertificateCheck natively.
    PS 5.1 applies the ServerCertificateValidationCallback workaround before each
    HTTPS request and always resets it to $null in a finally block -- failure to
    reset breaks all subsequent HTTPS calls in the session.
 
    The orchestrator gates this layer: it is only called if TCP layer 5 found at
    least one of 80, 8080, 443, or 8443 open. This function itself does not
    re-check open ports; it tries each port unconditionally (caller-gates).
 
    Prerequisites: $Context.NetworkProbeEnabled must be $true.
 
.PARAMETER IPAddress
    The RFC1918 / CGNAT / link-local IP address to probe.
 
.PARAMETER OpenPortsList
    Array of open ports from Get-VBTCPFingerprint. Used to skip ports known to
    be closed without attempting a connection. Optional -- if omitted all four
    ports are tried.
 
.PARAMETER Context
    Environment context from Get-VBEnrichmentContext. Provides
    NetworkProbeEnabled, CanSkipCertCheck, and DefaultTimeoutMs.HTTP.
 
.PARAMETER TimeoutMs
    HTTP request timeout in milliseconds. Defaults to $Context.DefaultTimeoutMs.HTTP (3000).
 
.OUTPUTS
    [PSCustomObject] -- base layer result fields plus:
        HTTPTitle [string]
        HTTPServer [string]
        HTTPPort [int]
        HTTPScheme [string] http | https
 
.EXAMPLE
    $ctx = Get-VBEnrichmentContext
    Get-VBHTTPBanner -IPAddress '192.168.1.45' -Context $ctx
 
.EXAMPLE
    Get-VBHTTPBanner -IPAddress '192.168.1.45' -OpenPortsList @(80,443) -Context $ctx
 
.NOTES
    Version: 1.0.0
    MinPSVersion: 5.1
    Author: VB
    ChangeLog:
        1.0.0 -- 2026-05-11 -- Initial release
#>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string]$IPAddress,

        [Parameter()]
        [int[]]$OpenPortsList = @(),

        [Parameter()]
        [PSCustomObject]$Context,

        [Parameter()]
        [int]$TimeoutMs = 3000
    )

    begin {
        $LAYER_NUM  = 6
        $LAYER_NAME = 'HTTP'

        if (-not $Context) {
            Write-Warning "[$LAYER_NAME] No context provided -- running without prerequisite validation."
        }

        if ($PSBoundParameters.ContainsKey('TimeoutMs') -eq $false -and $Context -and $Context.DefaultTimeoutMs) {
            $TimeoutMs = $Context.DefaultTimeoutMs.HTTP
        }

        $canSkipCert = $Context -and $Context.CanSkipCertCheck
        $psMajor     = if ($Context) { $Context.PSMajor } else { $PSVersionTable.PSVersion.Major }
    }

    process {
        $sw = [System.Diagnostics.Stopwatch]::StartNew()

        if ($Context -and -not $Context.NetworkProbeEnabled) {
            $sw.Stop()
            return New-VBLayerResult -IPAddress $IPAddress -Layer $LAYER_NUM -LayerName $LAYER_NAME `
                -Status 'Skipped' -ExecutionMs $sw.ElapsedMilliseconds `
                -SkipReason 'NetworkProbeDisabled' `
                -Impact 'No HTTP banner -- web-based device identification unavailable'
        }

        # Determine which ports to try based on what TCP found open
        $httpPorts = @(
            @{ Port = 80;   Scheme = 'http'  }
            @{ Port = 8080; Scheme = 'http'  }
            @{ Port = 443;  Scheme = 'https' }
            @{ Port = 8443; Scheme = 'https' }
        )

        if ($OpenPortsList.Count -gt 0) {
            $httpPorts = @($httpPorts | Where-Object { $OpenPortsList -contains $_.Port })
        }

        if ($httpPorts.Count -eq 0) {
            $sw.Stop()
            return New-VBLayerResult -IPAddress $IPAddress -Layer $LAYER_NUM -LayerName $LAYER_NAME `
                -Status 'NoResult' -ExecutionMs $sw.ElapsedMilliseconds
        }

        $timeoutSec = [math]::Max(1, [int][math]::Ceiling($TimeoutMs / 1000))

        foreach ($entry in $httpPorts) {
            $port   = $entry.Port
            $scheme = $entry.Scheme
            $uri    = "${scheme}://${IPAddress}:${port}/"

            $callbackSet = $false
            try {
                $response = $null

                if ($psMajor -ge 6) {
                    $response = Invoke-WebRequest -Uri $uri -TimeoutSec $timeoutSec `
                        -SkipCertificateCheck -UseBasicParsing -ErrorAction Stop
                }
                else {
                    # PS 5.1 -- apply cert bypass callback for HTTPS, always reset in finally
                    if ($scheme -eq 'https') {
                        [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
                        [Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
                        $callbackSet = $true
                    }
                    $response = Invoke-WebRequest -Uri $uri -TimeoutSec $timeoutSec `
                        -UseBasicParsing -ErrorAction Stop
                }

                # Extract <title>
                $title = $null
                if ($response.Content -match '<title[^>]*>(.*?)</title>') {
                    $title = [System.Web.HttpUtility]::HtmlDecode($Matches[1]).Trim()
                    # Collapse whitespace
                    $title = $title -replace '\s+', ' '
                }

                # Extract Server header
                $serverHeader = $null
                if ($response.Headers.ContainsKey('Server')) {
                    $serverHeader = ($response.Headers['Server'] -join ', ')
                }

                $sw.Stop()
                return New-VBLayerResult -IPAddress $IPAddress -Layer $LAYER_NUM -LayerName $LAYER_NAME `
                    -Status 'Success' -ExecutionMs $sw.ElapsedMilliseconds `
                    -ExtraFields @{
                        HTTPTitle  = $title
                        HTTPServer = $serverHeader
                        HTTPPort   = $port
                        HTTPScheme = $scheme
                    }
            }
            catch {
                Write-Verbose "[$LAYER_NAME] $uri failed: $($_.Exception.Message)"
                # Try next port
            }
            finally {
                if ($callbackSet) {
                    [Net.ServicePointManager]::ServerCertificateValidationCallback = $null
                }
            }
        }

        # All ports tried -- none responded successfully
        $sw.Stop()
        New-VBLayerResult -IPAddress $IPAddress -Layer $LAYER_NUM -LayerName $LAYER_NAME `
            -Status 'NoResult' -ExecutionMs $sw.ElapsedMilliseconds
    }
}