Scripts/VPNCertEval/VPNCertEval.Common.ps1
|
<#
.SYNOPSIS Shared helpers for the VPN certificate evaluation harness (Build-VPNCertEvalTargets.ps1 + Invoke-VPNCertEval.ps1). Dot-source this file from the same folder. .DESCRIPTION Pure/near-pure helpers - reachability probes lifted from Examples_Sources\User-UnitTests (3).ps1, plus PFX-stem parsing, subject-DN OU extraction, a certutil -verify verdict parser, a tiny .psd1 serializer, and the built-in service->port catalog. Nothing here connects a VPN or mutates a cert store; the runner does that. .NOTES Windows PowerShell 5.1 safe (no ternary, no ??, no classes). Get-NetAdapter / Get-NetIPAddress are used opportunistically for tunnel detail but the load-bearing up/down signal is a marker-host ping so it does not matter how FortiClient names (or whether it creates) an adapter. No top-level side effects - safe to dot-source. The entry scripts set their own Set-StrictMode. #> # -------------------------------------------------------------------------------------------------- # Property access that tolerates strict mode + missing members (JSON objects are ragged) # -------------------------------------------------------------------------------------------------- function Get-EvalProp { param([AllowNull()]$InputObject, [Parameter(Mandatory)][string]$Name, $Default = $null) if ($null -eq $InputObject) { return $Default } if ($InputObject -is [System.Collections.IDictionary]) { if ($InputObject.Contains($Name)) { return $InputObject[$Name] } else { return $Default } } $p = $InputObject.PSObject.Properties[$Name] if ($p) { return $p.Value } return $Default } # -------------------------------------------------------------------------------------------------- # Reachability probes (from User-UnitTests (3).ps1, parameterised on timeout) # -------------------------------------------------------------------------------------------------- function Test-EvalTcpPort { param([Parameter(Mandatory)][string]$ComputerName, [Parameter(Mandatory)][int]$Port, [int]$TimeoutMs = 500) $tcp = [System.Net.Sockets.TcpClient]::new() try { $ar = $tcp.BeginConnect($ComputerName, $Port, $null, $null) $ok = $ar.AsyncWaitHandle.WaitOne($TimeoutMs, $false) if ($ok) { $tcp.EndConnect($ar) } return [bool]$ok } catch { return $false } finally { $tcp.Close() } } function Test-EvalUdpPort { # UDP is connectionless: a timeout is ambiguous (open|filtered) and is reported as "not blocked". # Only an ICMP port-unreachable (ConnectionReset) is a definite closed/blocked. param([Parameter(Mandatory)][string]$ComputerName, [Parameter(Mandatory)][int]$Port, [int]$TimeoutMs = 500) $udp = [System.Net.Sockets.UdpClient]::new() $udp.Client.ReceiveTimeout = $TimeoutMs try { $udp.Connect($ComputerName, $Port) [void]$udp.Send([byte[]]@(0x00), 1) $ep = [System.Net.IPEndPoint]::new([System.Net.IPAddress]::Any, 0) [void]$udp.Receive([ref]$ep) return $true } catch [System.Net.Sockets.SocketException] { return ($_.Exception.SocketErrorCode -ne [System.Net.Sockets.SocketError]::ConnectionReset) } catch { return $false } finally { $udp.Close() } } function Test-EvalPing { param([Parameter(Mandatory)][string]$ComputerName, [int]$TimeoutMs = 500) $p = [System.Net.NetworkInformation.Ping]::new() try { $reply = $p.Send($ComputerName, $TimeoutMs) return ($reply.Status -eq [System.Net.NetworkInformation.IPStatus]::Success) } catch { return $false } finally { $p.Dispose() } } # -------------------------------------------------------------------------------------------------- # Tunnel state # -------------------------------------------------------------------------------------------------- function Get-EvalTunnelState { <# .SYNOPSIS Best-effort snapshot of whether a FortiClient VPN tunnel is up. -MarkerHost (an address that is ONLY reachable across the tunnel) is the authoritative signal; adapter details are extra. #> param([string]$MarkerHost, [int]$PingTimeoutMs = 800) $adapter = $null; $ip = $null; $dns = @() try { if (Get-Command Get-NetAdapter -ErrorAction SilentlyContinue) { $adapter = Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { ($_.InterfaceDescription -match 'Forti' -or $_.Name -match 'Forti|FortiClient|SSL ?VPN|IPsec') -and $_.Status -eq 'Up' } | Select-Object -First 1 } if ($adapter -and (Get-Command Get-NetIPAddress -ErrorAction SilentlyContinue)) { $ip = (Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -notmatch '^169\.254\.' } | Select-Object -First 1 -ExpandProperty IPAddress) } if ($adapter -and (Get-Command Get-DnsClientServerAddress -ErrorAction SilentlyContinue)) { $dns = @(Get-DnsClientServerAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | Select-Object -ExpandProperty ServerAddresses) } } catch { } $markerUp = $null if ($MarkerHost) { $markerUp = Test-EvalPing -ComputerName $MarkerHost -TimeoutMs $PingTimeoutMs } # Up if the marker answers; if no marker given, fall back to "a Forti adapter is up with a routable IP". $up = $false if ($null -ne $markerUp) { $up = [bool]$markerUp } elseif ($adapter -and $ip) { $up = $true } return [pscustomobject]@{ Up = $up MarkerHost = $MarkerHost MarkerPing = $markerUp AdapterName = if ($adapter) { $adapter.Name } else { $null } AssignedIP = $ip DnsServers = $dns } } function Wait-EvalTunnel { param( [Parameter(Mandatory)][ValidateSet('up', 'down')][string]$State, [string]$MarkerHost, [int]$TimeoutSec = 30, [int]$PollSec = 3 ) $deadline = (Get-Date).AddSeconds($TimeoutSec) $last = $null do { $last = Get-EvalTunnelState -MarkerHost $MarkerHost $hit = if ($State -eq 'up') { $last.Up } else { -not $last.Up } if ($hit) { return $last } Start-Sleep -Seconds $PollSec } while ((Get-Date) -lt $deadline) return $last } # -------------------------------------------------------------------------------------------------- # Certificate helpers # -------------------------------------------------------------------------------------------------- function Get-CertSubjectOU { <# Returns every OU= RDN value from a subject/DN string, outermost first as X.500 prints them. #> param([string]$Subject) if ([string]::IsNullOrWhiteSpace($Subject)) { return @() } $m = [regex]::Matches($Subject, 'OU=("(?:[^"]*)"|[^,]+)', 'IgnoreCase') $out = @() foreach ($x in $m) { $out += $x.Groups[1].Value.Trim().Trim('"') } return $out } function Get-CertUtilRevocationVerdict { <# .SYNOPSIS Classifies the text of `certutil -verify -urlfetch <cer>` into Good / Revoked / Undetermined, and notes whether a CRL and/or OCSP responder was actually consulted. #> param([Parameter(Mandatory)][AllowEmptyString()][string]$Text) $t = if ($null -eq $Text) { '' } else { $Text } $revoked = ($t -match 'CRYPT_E_REVOKED' -or $t -match '0x80092010' -or $t -match 'certificate is revoked' -or $t -match '\bis revoked\b') $offline = ($t -match 'CRYPT_E_REVOCATION_OFFLINE' -or $t -match '0x80092013' -or $t -match 'revocation server was offline' -or $t -match 'unable to check revocation' -or $t -match 'CRYPT_E_NO_REVOCATION_CHECK' -or $t -match '0x80092012' -or $t -match 'RETRIEVAL_FAILURE') $good = ($t -match 'Leaf certificate revocation check passed' -or $t -match '-verify command completed successfully') $verdict = 'Undetermined' if ($revoked) { $verdict = 'Revoked' } elseif ($good -and -not $offline) { $verdict = 'Good' } elseif ($offline) { $verdict = 'Undetermined' } $crlChecked = ($t -match 'Verifying CRL' -or $t -match 'Base CRL' -or $t -match 'Delta CRL' -or $t -match 'CRL[^\r\n]*Verified') $ocspChecked = ($t -match 'Verifying OCSP' -or $t -match '"OCSP"' -or $t -match 'OCSP[^\r\n]*Time:') return [pscustomobject]@{ Verdict = $verdict Revoked = [bool]$revoked Offline = [bool]$offline CrlChecked = [bool]$crlChecked OcspChecked = [bool]$ocspChecked Raw = $t } } # -------------------------------------------------------------------------------------------------- # PFX-suite inventory # -------------------------------------------------------------------------------------------------- function ConvertFrom-EvalPfxStem { <# .SYNOPSIS Splits a New-CATestCertSuite PFX file stem "<UserSanitized>_<LabelSanitized>_<valid|revoked>" back into its parts. Returns $null if the stem does not match. #> param([Parameter(Mandatory)][string]$Stem) $m = [regex]::Match($Stem, '^(?<user>[A-Za-z0-9]+)_(?<label>[A-Za-z0-9]+)_(?<kind>valid|revoked)$', 'IgnoreCase') if (-not $m.Success) { return $null } return [pscustomobject]@{ User = $m.Groups['user'].Value Label = $m.Groups['label'].Value Kind = $m.Groups['kind'].Value.ToLowerInvariant() } } function Get-EvalPfxInventory { <# .SYNOPSIS Enumerates *.pfx in -PfxDir, parses each stem, and (optionally) cross-references a <Company>_CertTestSuite_<ts>.txt summary for the authoritative serial / thumbprint / revoked flag. #> param([Parameter(Mandatory)][string]$PfxDir, [string]$SummaryPath) $summaryRows = @() if ($SummaryPath -and (Test-Path $SummaryPath)) { $lines = Get-Content -Path $SummaryPath $cur = $null foreach ($ln in $lines) { $mRow = [regex]::Match($ln, '^\s*(\S+)\s+(valid|revoked)\s+(\S+)\s+(\S+)\s+(True|False)\s+(.*)$', 'IgnoreCase') if ($mRow.Success) { $cur = [pscustomobject]@{ Label = $mRow.Groups[1].Value; Kind = $mRow.Groups[2].Value.ToLowerInvariant() User = $mRow.Groups[3].Value; Status = $mRow.Groups[4].Value RevokedConfirmed = [bool]::Parse($mRow.Groups[5].Value) Pfx = $mRow.Groups[6].Value.Trim(); Serial = $null; Thumbprint = $null } $summaryRows += $cur continue } $mSer = [regex]::Match($ln, 'serial\s+([0-9a-fA-F]+)\s+thumbprint\s+([0-9a-fA-F]+)', 'IgnoreCase') if ($mSer.Success -and $cur) { $cur.Serial = $mSer.Groups[1].Value; $cur.Thumbprint = $mSer.Groups[2].Value } } } $out = @() foreach ($f in (Get-ChildItem -Path $PfxDir -Filter '*.pfx' -File -ErrorAction SilentlyContinue | Sort-Object Name)) { $parts = ConvertFrom-EvalPfxStem -Stem $f.BaseName if (-not $parts) { continue } $match = $summaryRows | Where-Object { $_.Kind -eq $parts.Kind -and ( ($_.User -replace '[^A-Za-z0-9]', '') -ieq $parts.User -or ($_.Label -replace '[^A-Za-z0-9]', '') -ieq $parts.Label) } | Select-Object -First 1 $out += [pscustomobject]@{ PfxPath = $f.FullName FileName = $f.Name User = $parts.User Label = $parts.Label Kind = $parts.Kind ExpectRevoked = ($parts.Kind -eq 'revoked') SummaryLabel = if ($match) { $match.Label } else { $null } SerialFromSummary = if ($match) { $match.Serial } else { $null } ThumbFromSummary = if ($match) { $match.Thumbprint } else { $null } RevokeConfirmed = if ($match) { $match.RevokedConfirmed } else { $null } } } return $out } # -------------------------------------------------------------------------------------------------- # Quoted-list parsing (matches the CLIBuilder `"a" "b" "c"` convention used throughout the answers) # -------------------------------------------------------------------------------------------------- function ConvertFrom-EvalQuotedList { param([AllowEmptyString()][AllowNull()][string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return @() } $m = [regex]::Matches($Text, '"([^"]+)"') if ($m.Count -gt 0) { return @($m | ForEach-Object { $_.Groups[1].Value.Trim() } | Where-Object { $_ }) } return @($Text -split '\s+' | ForEach-Object { $_.Trim().Trim('"') } | Where-Object { $_ }) } # -------------------------------------------------------------------------------------------------- # Service catalogue (service name -> @{ TCP = @(ports); UDP = @(ports) }) # Seeded from User-UnitTests (3).ps1 $ServiceChecks; a -UnitTestsPath merge can override / extend. # -------------------------------------------------------------------------------------------------- function Get-EvalServiceCatalog { return [ordered]@{ 'Ping' = @{ TCP = @(); UDP = @() } # handled specially (ICMP) 'DNS' = @{ TCP = @(53); UDP = @(53) } 'NTP' = @{ TCP = @(); UDP = @(123) } 'Kerberos' = @{ TCP = @(88, 464); UDP = @(88, 464) } 'LDAP' = @{ TCP = @(389); UDP = @(389) } 'LDAPS' = @{ TCP = @(636); UDP = @() } 'GlobalCat' = @{ TCP = @(3268, 3269); UDP = @() } 'DCE-RPC' = @{ TCP = @(135); UDP = @(135) } 'DynRPC' = @{ TCP = @(49152); UDP = @() } 'RDP' = @{ TCP = @(3389); UDP = @(3389) } 'SMB' = @{ TCP = @(445); UDP = @() } 'SIP' = @{ TCP = @(5060); UDP = @(5060) } '3CX-5001' = @{ TCP = @(5001); UDP = @() } 'SSH' = @{ TCP = @(22); UDP = @() } 'Telnet' = @{ TCP = @(23); UDP = @() } 'HTTP' = @{ TCP = @(80); UDP = @() } 'HTTPS' = @{ TCP = @(443); UDP = @() } } } function Get-EvalServiceNamesForGroup { <# .SYNOPSIS Best-effort map from a FortiGate service-GROUP name (e.g. "IKEv2_AS400_Services") to a set of catalogue service names. Anything it cannot resolve returns @('Ping') plus a TODO note upstream. #> param([string]$ServiceGroupName) $n = "$ServiceGroupName" $set = New-Object System.Collections.Generic.List[string] if ($n -match 'RDS|RDP|Remote *Desktop|PlantDT') { $set.Add('RDP') } if ($n -match 'AS400|iSeries|5250') { $set.Add('RDP'); $set.Add('Telnet') } if ($n -match 'SMB|File|CIFS') { $set.Add('SMB') } if ($n -match 'DC|Domain|Kerb|LDAP') { $set.Add('Kerberos'); $set.Add('LDAP') } if ($n -match 'DNS') { $set.Add('DNS') } if ($n -match 'Web|HTTP|Portal') { $set.Add('HTTPS') } if ($n -match 'SIP|Phone|3CX|Voice') { $set.Add('SIP'); $set.Add('HTTPS') } if ($n -match 'HVAC|SSH') { $set.Add('SSH') } if ($set.Count -eq 0) { $set.Add('Ping') } if (-not $set.Contains('Ping')) { $set.Add('Ping') } return ($set.ToArray() | Select-Object -Unique) } function Get-EvalServicesForLabelText { <# Infers services from a free-text endpoint label (used when folding in a User-UnitTests file). #> param([string]$Text) $n = "$Text" $set = New-Object System.Collections.Generic.List[string] if ($n -match '\bDC\b|Domain Controller') { 'DNS','Kerberos','LDAP','LDAPS','GlobalCat','DCE-RPC' | ForEach-Object { $set.Add($_) } } if ($n -match 'File ?Server|Fileshare|\bFS\b|SMB') { $set.Add('SMB'); $set.Add('Kerberos') } if ($n -match 'RDS|RDP|Remote Desktop') { $set.Add('RDP') } if ($n -match '3CX|Phone|SIP|Voice') { $set.Add('SIP'); $set.Add('HTTPS'); $set.Add('3CX-5001') } if ($n -match 'HVAC') { $set.Add('SSH'); $set.Add('Telnet'); $set.Add('HTTPS') } if ($n -match 'HMRS|MAS90|Util|MercyOne|ISDG|Houlihan|Sage') { $set.Add('RDP') } if ($set.Count -eq 0) { $set.Add('Ping'); $set.Add('RDP') } if (-not $set.Contains('Ping')) { $set.Add('Ping') } return ($set.ToArray() | Select-Object -Unique) } # -------------------------------------------------------------------------------------------------- # Reachability matrix # -------------------------------------------------------------------------------------------------- function Invoke-EvalReachabilityMatrix { <# .SYNOPSIS Probes a list of endpoint objects (@{ Host; Name; Services=@(...); Expect='Allow'|'Block' }) against the port catalogue and returns one result row per endpoint with a Verdict + Match. .PARAMETER ProbeOverride Test seam: scriptblock ($HostName, $Proto('tcp'|'udp'|'icmp'), $Port) -> [bool]. When supplied, no real sockets are opened. #> param( [Parameter(Mandatory)]$Endpoints, [Parameter(Mandatory)]$ServiceChecks, [int]$TimeoutMs = 500, [int]$PingTimeoutMs = 500, [scriptblock]$ProbeOverride ) $probe = { param($HostName, $Proto, $Port) if ($ProbeOverride) { return [bool](& $ProbeOverride $HostName $Proto $Port) } switch ($Proto) { 'icmp' { return Test-EvalPing -ComputerName $HostName -TimeoutMs $PingTimeoutMs } 'tcp' { return Test-EvalTcpPort -ComputerName $HostName -Port $Port -TimeoutMs $TimeoutMs } 'udp' { return Test-EvalUdpPort -ComputerName $HostName -Port $Port -TimeoutMs $TimeoutMs } } return $false } $rows = New-Object System.Collections.Generic.List[object] foreach ($ep in $Endpoints) { $epHost = Get-EvalProp $ep 'Host' $epName = Get-EvalProp $ep 'Name' $epHost $epExpect = Get-EvalProp $ep 'Expect' 'Allow' $epSvcs = Get-EvalProp $ep 'Services' @('Ping') if (-not $epSvcs -or @($epSvcs).Count -eq 0) { $epSvcs = @('Ping') } $perSvc = [ordered]@{} $anyOpen = $false foreach ($svc in $epSvcs) { if ($svc -eq 'Ping') { $r = [bool](& $probe $epHost 'icmp' 0) $perSvc[$svc] = $r if ($r) { $anyOpen = $true } continue } $def = $ServiceChecks[$svc] if (-not $def) { $perSvc[$svc] = $null; continue } $results = @() foreach ($p in @(Get-EvalProp $def 'TCP' @())) { $results += [bool](& $probe $epHost 'tcp' $p) } foreach ($p in @(Get-EvalProp $def 'UDP' @())) { $results += [bool](& $probe $epHost 'udp' $p) } $svcOpen = if ($results.Count -eq 0) { $null } else { ($results -contains $true) } $perSvc[$svc] = $svcOpen if ($svcOpen -eq $true) { $anyOpen = $true } } $verdict = if ($anyOpen) { 'Allow' } else { 'Block' } $rows.Add([pscustomobject]@{ Host = $epHost Name = $epName Expect = $epExpect Verdict = $verdict Match = ($verdict -ieq $epExpect) PerService = $perSvc }) } return $rows } # -------------------------------------------------------------------------------------------------- # Targets file I/O # -------------------------------------------------------------------------------------------------- function Read-EvalTargets { param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path $Path)) { throw "Targets file not found: $Path" } if (Get-Command Import-PowerShellDataFile -ErrorAction SilentlyContinue) { return Import-PowerShellDataFile -Path $Path } $raw = Get-Content -Path $Path -Raw return (Invoke-Expression $raw) } function ConvertTo-EvalPsd1 { <# Minimal, safe .psd1 serializer: strings / ints / bools / $null / arrays / (ordered)hashtables. #> param([Parameter(Mandatory)][AllowNull()]$InputObject, [int]$Indent = 0) $pad = ' ' * ($Indent * 4) $pad2 = ' ' * (($Indent + 1) * 4) if ($null -eq $InputObject) { return '$null' } if ($InputObject -is [bool]) { return $(if ($InputObject) { '$true' } else { '$false' }) } if ($InputObject -is [int] -or $InputObject -is [long] -or $InputObject -is [double] -or $InputObject -is [decimal]) { return "$InputObject" } if ($InputObject -is [string]) { return "'" + ($InputObject -replace "'", "''") + "'" } if ($InputObject -is [System.Collections.IDictionary]) { if ($InputObject.Count -eq 0) { return '@{}' } $sb = New-Object System.Text.StringBuilder [void]$sb.AppendLine('@{') foreach ($k in $InputObject.Keys) { $keyText = if ("$k" -match '^[A-Za-z_][A-Za-z0-9_]*$') { "$k" } else { "'" + ("$k" -replace "'", "''") + "'" } $valText = ConvertTo-EvalPsd1 -InputObject $InputObject[$k] -Indent ($Indent + 1) [void]$sb.AppendLine("$pad2$keyText = $valText") } [void]$sb.Append("$pad}") return $sb.ToString() } if ($InputObject -is [System.Collections.IEnumerable]) { # NB: @($list) crashes the PS7 binder when the list holds IDictionary elements - collect by hand. $items = New-Object System.Collections.ArrayList foreach ($x in $InputObject) { [void]$items.Add($x) } if ($items.Count -eq 0) { return '@()' } $sb = New-Object System.Text.StringBuilder [void]$sb.AppendLine('@(') for ($i = 0; $i -lt $items.Count; $i++) { $valText = ConvertTo-EvalPsd1 -InputObject $items[$i] -Indent ($Indent + 1) $comma = if ($i -lt $items.Count - 1) { ',' } else { '' } [void]$sb.AppendLine("$pad2$valText$comma") } [void]$sb.Append("$pad)") return $sb.ToString() } return "'" + ("$InputObject" -replace "'", "''") + "'" } function ConvertFrom-UnitTestsFile { <# .SYNOPSIS Pulls the $Endpoints and $ServiceChecks literals out of a User-UnitTests-style .ps1 via AST (no execution of the rest of the file). Returns @{ Endpoints = [ordered]; ServiceChecks = [ordered] }. #> param([Parameter(Mandatory)][string]$Path) $tokens = $null; $perr = $null $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$perr) if ($perr.Count -gt 0) { throw "Parse errors in $Path" } $grab = { param($VarName) $node = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.AssignmentStatementAst] -and $n.Left -is [System.Management.Automation.Language.VariableExpressionAst] -and $n.Left.VariablePath.UserPath -eq $VarName }, $true) | Select-Object -First 1 if (-not $node) { return $null } return (Invoke-Expression $node.Right.Extent.Text) } return @{ Endpoints = (& $grab 'Endpoints') ServiceChecks = (& $grab 'ServiceChecks') } } |