Scripts/VPNCertEval/Invoke-VPNCertEval.ps1
|
<#
.SYNOPSIS Steps through the PFX files produced by CA-Manager menu 8 (New-CATestCertSuite) on a test box: for each cert it imports it, guides a FortiClient connect, records whether the tunnel came up and what the account could reach, then does an offline CRL/OCSP verification with certutil and writes a results report. .DESCRIPTION Per PFX (stem "<user>_<label>_<valid|revoked>"): 1. Import-PfxCertificate into Cert:\CurrentUser\My (or -CertStore LocalMachine). 2. Show the cert (subject, OU path, validity, serial) and the EXPECTED outcome (valid -> should connect + full rights ; revoked -> FortiGate should reject at IKE phase 1). 3. You connect FortiClient with this cert and clear MFA. The harness auto-detects the tunnel via a marker-host ping (there is no reliable scripted connect for FortiClient IPsec, and MFA is interactive - so the connect click stays manual; everything else is automated). 4. If up: run the reachability matrix for this cert's group (Universal + group Endpoints + group CrossCheckBlocked) from the -TargetsPath file. Compare Verdict vs Expect. 5. You disconnect; the harness confirms the tunnel dropped. 6. Export the public cert and run certutil -f -urlfetch -verify -> Good / Revoked / Undetermined. 7. Remove the imported cert (unless -KeepCerts). Then: a console table + <Company>_CertEval_<ts>.txt / .json always, plus an .xlsx with green/red conditional formatting when the ImportExcel module is present. .PARAMETER PfxDir Folder holding the *.pfx files (CA-Manager menu 8 default was C:\Admin\TestPFX). .PARAMETER TargetsPath <Company>_EvalTargets.psd1 from Build-VPNCertEvalTargets.ps1. If omitted, the reachability step is skipped (connect + certutil only). .PARAMETER SummaryPath Optional <Company>_CertTestSuite_<ts>.txt - cross-references serial / thumbprint / revoked flag. .PARAMETER MarkerHost An address reachable ONLY over the tunnel, used to detect up/down. Defaults to the first Universal endpoint in the targets file. .EXAMPLE .\Invoke-VPNCertEval.ps1 -PfxDir C:\Admin\TestPFX -TargetsPath C:\Admin\TestPFX\EXAMPLE_EvalTargets.psd1 ` -SummaryPath C:\Admin\TestPFX\EXAMPLE_CertTestSuite_20260909_101500.txt #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$PfxDir, [string]$TargetsPath, [string]$SummaryPath, [string]$OutDir, [string]$PfxPassword, [ValidateSet('CurrentUser', 'LocalMachine')][string]$CertStore = 'CurrentUser', [string]$MarkerHost, [int]$ConnectWaitSec = 240, [int]$TcpTimeoutMs = 500, [int]$PingTimeoutMs = 500, [switch]$SkipConnect, [switch]$AttemptCliConnect, [switch]$KeepCerts ) Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' . (Join-Path $PSScriptRoot 'VPNCertEval.Common.ps1') if (-not (Test-Path $PfxDir)) { throw "PfxDir not found: $PfxDir" } if (-not $OutDir) { $OutDir = Join-Path $PfxDir 'EvalResults' } if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir -Force | Out-Null } $stamp = Get-Date -Format 'yyyyMMdd_HHmmss' # --- password --------------------------------------------------------------------------------- $securePw = $null if ($PfxPassword) { $securePw = ConvertTo-SecureString -String $PfxPassword -AsPlainText -Force } else { $securePw = Read-Host "Shared PFX password for the test suite" -AsSecureString } # --- inventory ------------------------------------------------------------------------------- $inv = @(Get-EvalPfxInventory -PfxDir $PfxDir -SummaryPath $SummaryPath) if ($inv.Count -eq 0) { throw "No PFX files with a '<user>_<label>_<valid|revoked>' name found in $PfxDir" } # --- targets -------------------------------------------------------------------------------- $targets = $null if ($TargetsPath) { $targets = Read-EvalTargets -Path $TargetsPath if (-not $MarkerHost -and $targets.Universal -and @($targets.Universal).Count -gt 0) { $MarkerHost = @($targets.Universal)[0].Host } } $company = if ($targets -and $targets.Company) { "$($targets.Company)" } else { 'VPN' } Write-Host "" Write-Host "==== VPN cert evaluation : $($inv.Count) PFX file(s) ====" -ForegroundColor Cyan Write-Host (" PFX dir : {0}" -f $PfxDir) Write-Host (" Targets : {0}" -f $(if ($TargetsPath) { $TargetsPath } else { '(none - reachability skipped)' })) Write-Host (" Marker host : {0}" -f $(if ($MarkerHost) { $MarkerHost } else { '(none - adapter heuristic only)' })) Write-Host (" Cert store : Cert:\{0}\My" -f $CertStore) Write-Host (" Out dir : {0}" -f $OutDir) Write-Host "" if ($MarkerHost) { $base = Get-EvalTunnelState -MarkerHost $MarkerHost -PingTimeoutMs $PingTimeoutMs if ($base.Up) { Write-Host "WARNING: marker host is reachable right now - a tunnel appears to already be UP." -ForegroundColor Yellow Write-Host " Disconnect any VPN before starting so per-cert results aren't contaminated." -ForegroundColor Yellow Read-Host "Press Enter once disconnected (or Ctrl+C to abort)" | Out-Null } } function Read-KeyChoice { param([string]$Prompt, [string[]]$Valid) while ($true) { $r = (Read-Host $Prompt).Trim() if ([string]::IsNullOrEmpty($r) -and $Valid -contains '') { return '' } foreach ($v in $Valid) { if ($v -and $r -match "^[$v]") { return $r.Substring(0, 1).ToUpperInvariant() } } Write-Host " (expected one of: $($Valid -join ', '))" -ForegroundColor DarkGray } } $results = New-Object System.Collections.Generic.List[object] $reachRows = New-Object System.Collections.Generic.List[object] foreach ($item in $inv) { Write-Host "" Write-Host ("------------------------------------------------------------" ) -ForegroundColor DarkCyan Write-Host (" {0} [{1} / {2}]" -f $item.FileName, $item.Label, $item.Kind) -ForegroundColor Cyan Write-Host ("------------------------------------------------------------" ) -ForegroundColor DarkCyan $row = [ordered]@{ FileName = $item.FileName Label = $item.Label Kind = $item.Kind ExpectRevoked = $item.ExpectRevoked Subject = $null OUPath = $null Serial = $null Thumbprint = $null NotAfter = $null Connected = $null AssignedIP = $null RightsAllowOK = $null RightsAllowFail= $null RightsBlockOK = $null RightsLeak = $null RightsMatch = $null RevocationVerdict = $null CrlChecked = $null OcspChecked = $null Outcome = $null Notes = $null } # 1. import ------------------------------------------------------------------------------- $imp = $null try { $imp = Import-PfxCertificate -FilePath $item.PfxPath -CertStoreLocation ("Cert:\{0}\My" -f $CertStore) -Password $securePw -Exportable -ErrorAction Stop } catch { $row.Outcome = 'ERROR' $row.Notes = "import failed: $($_.Exception.Message)" Write-Host " import FAILED: $($_.Exception.Message)" -ForegroundColor Red $results.Add([pscustomobject]$row) continue } $cert = Get-Item ("Cert:\{0}\My\{1}" -f $CertStore, $imp.Thumbprint) $row.Subject = $cert.Subject $row.OUPath = (Get-CertSubjectOU -Subject $cert.Subject) -join ' / ' $row.Serial = $cert.SerialNumber $row.Thumbprint = $cert.Thumbprint $row.NotAfter = $cert.NotAfter.ToString('yyyy-MM-dd') Write-Host (" Subject : {0}" -f $cert.Subject) -ForegroundColor Gray Write-Host (" OU path : {0}" -f $(if ($row.OUPath) { $row.OUPath } else { '(none)' })) -ForegroundColor Gray Write-Host (" Serial : {0} NotAfter : {1}" -f $cert.SerialNumber, $row.NotAfter) -ForegroundColor Gray if ($item.ExpectRevoked) { Write-Host " EXPECTED: this cert is REVOKED - FortiGate phase 1 should FAIL (certificate revoked)." -ForegroundColor Yellow } else { Write-Host " EXPECTED: this cert is VALID - tunnel should come up and reachability should match the group scope." -ForegroundColor Green } # 2. connect --------------------------------------------------------------------------- $grp = $null if ($targets) { $grp = @($targets.Groups) | Where-Object { $_.Label -ieq $item.Label } | Select-Object -First 1 if (-not $grp) { $grp = @($targets.Groups) | Where-Object { "$($_.Label)".ToLower().Contains($item.Label.ToLower()) -or $item.Label.ToLower().Contains("$($_.Label)".ToLower()) } | Select-Object -First 1 } if (-not $grp) { Write-Host (" NOTE: no group in the targets file matches label '{0}' - reachability will use Universal only." -f $item.Label) -ForegroundColor DarkYellow } } if ($SkipConnect) { Write-Host " -SkipConnect: not testing the tunnel for this cert." -ForegroundColor DarkGray $row.Connected = $null } else { if ($AttemptCliConnect) { $fc = @("${env:ProgramFiles}\Fortinet\FortiClient\FortiClient.exe", "${env:ProgramFiles(x86)}\Fortinet\FortiClient\FortiClient.exe") | Where-Object { Test-Path $_ } | Select-Object -First 1 if ($fc) { Write-Host " (best-effort) launching FortiClient - complete the connect + MFA in its window..." -ForegroundColor DarkGray try { Start-Process -FilePath $fc | Out-Null } catch { } } } Write-Host "" Write-Host " >> Connect FortiClient now using this certificate, and approve MFA if prompted." -ForegroundColor White $choice = Read-KeyChoice -Prompt " Press ENTER when the tunnel is UP | F = it failed / was rejected | S = skip" -Valid @('', 'F', 'S') if ($choice -eq 'F') { $row.Connected = $false $row.Notes = 'tester reported connect failure / IKE rejection' Write-Host " recorded: NOT connected (reported failure)" -ForegroundColor Yellow } elseif ($choice -eq 'S') { $row.Connected = $null $row.Notes = 'connect step skipped by tester' Write-Host " recorded: skipped" -ForegroundColor DarkGray } else { $st = Wait-EvalTunnel -State up -MarkerHost $MarkerHost -TimeoutSec 25 -PollSec 3 $row.Connected = [bool]$st.Up $row.AssignedIP = $st.AssignedIP if ($st.Up) { Write-Host (" tunnel UP (adapter {0}, IP {1})" -f $st.AdapterName, $st.AssignedIP) -ForegroundColor Green } else { Write-Host " could not confirm the tunnel is up (marker host not answering)." -ForegroundColor Yellow $row.Notes = 'tester said UP but marker host did not respond' } } } # 3. reachability -------------------------------------------------------------------- if ($row.Connected -eq $true -and $targets) { $eps = New-Object System.Collections.Generic.List[object] foreach ($e in @($targets.Universal)) { $eps.Add($e) } if ($grp) { foreach ($e in @($grp.Endpoints)) { $eps.Add($e) } foreach ($e in @($grp.CrossCheckBlocked)) { $eps.Add($e) } } $probeEps = @($eps | Where-Object { $_.Host -and $_.Host -notmatch 'RESOLVE-ME|CHANGE-ME' }) Write-Host (" probing {0} endpoint(s)..." -f $probeEps.Count) -ForegroundColor Gray $matrix = Invoke-EvalReachabilityMatrix -Endpoints $probeEps -ServiceChecks $targets.ServiceChecks -TimeoutMs $TcpTimeoutMs -PingTimeoutMs $PingTimeoutMs $allowOK = 0; $allowFail = 0; $blockOK = 0; $leak = 0 foreach ($m in $matrix) { $reachRows.Add([pscustomobject]@{ Cert = $item.FileName; Label = $item.Label; Kind = $item.Kind Endpoint = $m.Host; Name = $m.Name; Expect = $m.Expect; Verdict = $m.Verdict Match = $m.Match; Services = (($m.PerService.GetEnumerator() | ForEach-Object { "{0}={1}" -f $_.Key, $_.Value }) -join ' ') }) if ($m.Expect -ieq 'Allow') { if ($m.Verdict -eq 'Allow') { $allowOK++ } else { $allowFail++ } } else { if ($m.Verdict -eq 'Block') { $blockOK++ } else { $leak++ } } } $row.RightsAllowOK = $allowOK; $row.RightsAllowFail = $allowFail $row.RightsBlockOK = $blockOK; $row.RightsLeak = $leak $row.RightsMatch = ($allowFail -eq 0 -and $leak -eq 0) $col = if ($row.RightsMatch) { 'Green' } else { 'Yellow' } Write-Host (" rights: Allow {0}/{1} reachable, Block {2}/{3} correctly denied{4}" -f ` $allowOK, ($allowOK + $allowFail), $blockOK, ($blockOK + $leak), $(if ($leak) { " <-- $leak LEAK(S)" } else { '' })) -ForegroundColor $col } # 4. disconnect -------------------------------------------------------------------- if (-not $SkipConnect -and $row.Connected -eq $true) { Write-Host "" Read-Host " >> Disconnect FortiClient now, then press ENTER" | Out-Null $dn = Wait-EvalTunnel -State down -MarkerHost $MarkerHost -TimeoutSec 30 -PollSec 3 if ($dn.Up) { Write-Host " marker host still answering - tunnel may not be fully down." -ForegroundColor Yellow } else { Write-Host " tunnel down." -ForegroundColor Gray } } # 5. certutil verify -------------------------------------------------------------- $stem = "{0}_{1}_{2}" -f $item.User, $item.Label, $item.Kind $cerPath = Join-Path $OutDir "$stem.cer" try { Export-Certificate -Cert $cert -FilePath $cerPath -Type CERT -Force | Out-Null $vtext = (& certutil.exe -f -urlfetch -verify $cerPath 2>&1 | Out-String) Set-Content -Path (Join-Path $OutDir "${stem}_certutil.txt") -Value $vtext -Encoding UTF8 $verdict = Get-CertUtilRevocationVerdict -Text $vtext $row.RevocationVerdict = $verdict.Verdict $row.CrlChecked = $verdict.CrlChecked $row.OcspChecked = $verdict.OcspChecked $vc = switch ($verdict.Verdict) { 'Good' { 'Green' } 'Revoked' { 'Yellow' } default { 'DarkYellow' } } Write-Host (" certutil: {0} (CRL checked={1}, OCSP checked={2})" -f $verdict.Verdict, $verdict.CrlChecked, $verdict.OcspChecked) -ForegroundColor $vc } catch { $row.RevocationVerdict = 'ERROR' Write-Host " certutil verify FAILED: $($_.Exception.Message)" -ForegroundColor Red } # 6. cleanup -------------------------------------------------------------------- if (-not $KeepCerts) { try { Remove-Item ("Cert:\{0}\My\{1}" -f $CertStore, $imp.Thumbprint) -Force -ErrorAction Stop; Write-Host " removed cert from Cert:\$CertStore\My" -ForegroundColor DarkGray } catch { Write-Host " could not remove cert (elevation? LocalMachine store): $($_.Exception.Message)" -ForegroundColor DarkYellow } } # 7. outcome -------------------------------------------------------------------- if ($item.ExpectRevoked) { if ($row.Connected -eq $false -and $row.RevocationVerdict -eq 'Revoked') { $row.Outcome = 'PASS' } elseif ($row.Connected -eq $true) { $row.Outcome = 'FAIL'; $row.Notes = (@($row.Notes, 'revoked cert CONNECTED - FortiGate not enforcing revocation (CRL/OCSP cache?)') | Where-Object { $_ }) -join '; ' } elseif ($row.RevocationVerdict -ne 'Revoked') { $row.Outcome = 'WARN'; $row.Notes = (@($row.Notes, "certutil says '$($row.RevocationVerdict)' not 'Revoked'") | Where-Object { $_ }) -join '; ' } else { $row.Outcome = 'WARN' } } else { if ($row.Connected -eq $true -and $row.RevocationVerdict -eq 'Good' -and $row.RightsMatch -ne $false) { $row.Outcome = 'PASS' } elseif ($row.Connected -eq $false) { $row.Outcome = 'FAIL'; $row.Notes = (@($row.Notes, 'valid cert did NOT connect') | Where-Object { $_ }) -join '; ' } elseif ($row.RightsMatch -eq $false) { $row.Outcome = 'WARN'; $row.Notes = (@($row.Notes, 'reachability did not match expected scope') | Where-Object { $_ }) -join '; ' } elseif ($row.RevocationVerdict -ne 'Good') { $row.Outcome = 'WARN'; $row.Notes = (@($row.Notes, "certutil verdict '$($row.RevocationVerdict)'") | Where-Object { $_ }) -join '; ' } else { $row.Outcome = 'WARN' } } $oc = switch ($row.Outcome) { 'PASS' { 'Green' } 'FAIL' { 'Red' } default { 'Yellow' } } Write-Host (" OUTCOME: {0}" -f $row.Outcome) -ForegroundColor $oc $results.Add([pscustomobject]$row) } # --- reports ----------------------------------------------------------------------------------- $txtPath = Join-Path $OutDir ("{0}_CertEval_{1}.txt" -f ($company -replace '[^A-Za-z0-9]', ''), $stamp) $jsonPath = Join-Path $OutDir ("{0}_CertEval_{1}.json" -f ($company -replace '[^A-Za-z0-9]', ''), $stamp) $lines = New-Object System.Collections.Generic.List[string] $lines.Add("VPN certificate evaluation - $company") $lines.Add("Generated : $(Get-Date)") $lines.Add("PFX dir : $PfxDir") $lines.Add("Targets : $(if ($TargetsPath) { $TargetsPath } else { '(none)' })") $lines.Add("") $lines.Add(("{0,-34} {1,-12} {2,-8} {3,-10} {4,-14} {5,-8} {6}" -f 'PFX', 'Label', 'Kind', 'Connected', 'Revocation', 'Rights', 'Outcome')) $lines.Add(('-' * 110)) foreach ($r in $results) { $rights = if ($null -eq $r.RightsMatch) { '-' } elseif ($r.RightsMatch) { 'match' } else { "LEAK/$($r.RightsLeak)" } $lines.Add(("{0,-34} {1,-12} {2,-8} {3,-10} {4,-14} {5,-8} {6}" -f ` $r.FileName, $r.Label, $r.Kind, "$($r.Connected)", "$($r.RevocationVerdict)", $rights, $r.Outcome)) if ($r.Notes) { $lines.Add(("{0,-34} -> {1}" -f '', $r.Notes)) } } $lines.Add("") $lines.Add("Per-endpoint reachability") $lines.Add(('-' * 110)) $lines.Add(("{0,-24} {1,-10} {2,-8} {3,-8} {4,-6} {5}" -f 'Cert', 'Label', 'Expect', 'Verdict', 'OK', 'Endpoint')) foreach ($m in $reachRows) { $lines.Add(("{0,-24} {1,-10} {2,-8} {3,-8} {4,-6} {5} [{6}]" -f ` [IO.Path]::GetFileNameWithoutExtension($m.Cert), $m.Label, $m.Expect, $m.Verdict, "$($m.Match)", $m.Endpoint, $m.Services)) } $pass = @($results | Where-Object { $_.Outcome -eq 'PASS' }).Count $fail = @($results | Where-Object { $_.Outcome -eq 'FAIL' }).Count $warn = @($results | Where-Object { $_.Outcome -notin @('PASS', 'FAIL') }).Count $lines.Add("") $lines.Add("TOTALS: $pass PASS $fail FAIL $warn WARN/other ($($results.Count) certs)") Set-Content -Path $txtPath -Value $lines -Encoding UTF8 [pscustomobject]@{ Company = $company; GeneratedUtc = (Get-Date).ToUniversalTime().ToString('s') + 'Z' Summary = $results; Reachability = $reachRows } | ConvertTo-Json -Depth 8 | Set-Content -Path $jsonPath -Encoding UTF8 # --- optional Excel -------------------------------------------------------------------------- $xlsxPath = Join-Path $OutDir ("{0}_CertEval_{1}.xlsx" -f ($company -replace '[^A-Za-z0-9]', ''), $stamp) if (Get-Module -ListAvailable -Name ImportExcel) { try { Import-Module ImportExcel -ErrorAction Stop $results | Select-Object FileName, Label, Kind, ExpectRevoked, Connected, AssignedIP, RightsAllowOK, RightsAllowFail, RightsBlockOK, RightsLeak, RightsMatch, RevocationVerdict, CrlChecked, OcspChecked, Outcome, Notes | Export-Excel -Path $xlsxPath -WorksheetName 'Summary' -AutoSize -AutoFilter -FreezeTopRow -BoldTopRow -TableStyle 'Medium6' if ($reachRows.Count) { $reachRows | Export-Excel -Path $xlsxPath -WorksheetName 'Reachability' -AutoSize -AutoFilter -FreezeTopRow -BoldTopRow -TableStyle 'Medium2' } $pkg = Open-ExcelPackage -Path $xlsxPath foreach ($wsName in @('Summary', 'Reachability')) { $ws = $pkg.Workbook.Worksheets[$wsName] if (-not $ws -or -not $ws.Dimension) { continue } $rng = [OfficeOpenXml.ExcelCellBase]::GetAddress(2, 1, $ws.Dimension.End.Row, $ws.Dimension.End.Column) foreach ($pair in @(@('"PASS"', 198, 239, 206, 0, 97, 0), @('"True"', 198, 239, 206, 0, 97, 0), @('"Allow"', 198, 239, 206, 0, 97, 0), @('"FAIL"', 255, 199, 206, 156, 0, 6), @('"False"', 255, 199, 206, 156, 0, 6))) { $cf = $ws.ConditionalFormatting.AddEqual($rng) $cf.Formula = $pair[0] $cf.Style.Fill.PatternType = [OfficeOpenXml.Style.ExcelFillStyle]::Solid $cf.Style.Fill.BackgroundColor.SetColor([System.Drawing.Color]::FromArgb($pair[1], $pair[2], $pair[3])) $cf.Style.Font.Color.SetColor([System.Drawing.Color]::FromArgb($pair[4], $pair[5], $pair[6])) } } Close-ExcelPackage $pkg Write-Host "" Write-Host "Excel : $xlsxPath" -ForegroundColor Green } catch { Write-Host "Excel export skipped: $($_.Exception.Message)" -ForegroundColor DarkYellow } } else { Write-Host "" Write-Host "(ImportExcel module not installed - .xlsx skipped; .txt/.json written)" -ForegroundColor DarkGray } Write-Host "" Write-Host "Report : $txtPath" -ForegroundColor Green Write-Host "JSON : $jsonPath" -ForegroundColor Green $totColor = if ($fail -eq 0 -and $warn -eq 0) { 'Green' } elseif ($fail -eq 0) { 'Yellow' } else { 'Red' } Write-Host ("TOTALS : {0} PASS {1} FAIL {2} WARN/other ({3} certs)" -f $pass, $fail, $warn, $results.Count) -ForegroundColor $totColor Write-Host "" $results | Format-Table FileName, Label, Kind, Connected, RevocationVerdict, RightsMatch, Outcome -AutoSize |