#Requires -Version 5.1 <# .SYNOPSIS Validates host-side security prerequisites for the CI system. .DESCRIPTION Checks that: 1. The SSH private key for Linux guest access exists and has restrictive ACLs (only the current user and SYSTEM/Administrators may read it). 2. The Windows Credential Manager target used by CI guest sessions is accessible. 3. The CI key directories exist with expected permissions. Run after `Setup-Host.ps1` and any time credentials or keys are rotated. Exits with code 0 on all-pass, non-zero if any check fails. .PARAMETER SshKeyPath Path to the SSH private key used for Linux guest access. Default: F:\CI\keys\ci_linux .PARAMETER GuestCredTarget Windows Credential Manager target name for Windows guest credentials. Default: BuildVMGuest .PARAMETER CIRootDir Root CI directory to verify existence. Default: F:\CI .EXAMPLE .\Validate-HostState.ps1 .EXAMPLE .\Validate-HostState.ps1 -SshKeyPath 'F:\CI\keys\ci_linux' -Verbose #> [CmdletBinding()] param( [string] $SshKeyPath = 'F:\CI\keys\ci_linux', [string] $GuestCredTarget = 'BuildVMGuest', [string] $CIRootDir = 'F:\CI' ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # ── Helpers ─────────────────────────────────────────────────────────────────── $script:passCount = 0 $script:failCount = 0 function Write-Check { param([string]$Label, [scriptblock]$Test) try { $result = & $Test if ($result) { Write-Host " [OK] $Label" $script:passCount++ } else { Write-Host " [FAIL] $Label" $script:failCount++ } } catch { Write-Host " [FAIL] $Label — $_" $script:failCount++ } } # ── Check 1: SSH key file exists ────────────────────────────────────────────── Write-Host "`n=== SSH Key ($SshKeyPath) ===" Write-Check "SSH private key file exists" { Test-Path $SshKeyPath -PathType Leaf } Write-Check "SSH public key file exists" { Test-Path "${SshKeyPath}.pub" -PathType Leaf } # Check ACLs: only SYSTEM, Administrators, and the current user should have access Write-Check "SSH key ACL — no unexpected identities" { if (-not (Test-Path $SshKeyPath -PathType Leaf)) { return $false } $acl = Get-Acl -Path $SshKeyPath $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name # Allowed identities (case-insensitive prefix matching) $allowedPatterns = @( 'NT AUTHORITY\SYSTEM', 'BUILTIN\Administrators', $currentUser ) $unexpected = @() foreach ($ace in $acl.Access) { $identity = $ace.IdentityReference.Value $matched = $false foreach ($pattern in $allowedPatterns) { if ($identity -ieq $pattern) { $matched = $true; break } } if (-not $matched) { $unexpected += $identity } } if ($unexpected.Count -gt 0) { Write-Verbose "[ACL] Unexpected identities on SSH key: $($unexpected -join ', ')" return $false } return $true } Write-Check "SSH key not world-readable (Everyone/Users absent)" { if (-not (Test-Path $SshKeyPath -PathType Leaf)) { return $false } $acl = Get-Acl -Path $SshKeyPath $broad = $acl.Access | Where-Object { $id = $_.IdentityReference.Value $id -ilike '*Everyone*' -or $id -ilike 'BUILTIN\Users' } return ($null -eq $broad -or @($broad).Count -eq 0) } # ── Check 2: Credential Manager target ──────────────────────────────────────── Write-Host "`n=== Credential Manager (target: $GuestCredTarget) ===" Write-Check "CredentialManager module available" { $null -ne (Get-Module -Name CredentialManager -ListAvailable -ErrorAction SilentlyContinue) } Write-Check "Credential target '$GuestCredTarget' accessible" { # Use cmdkey.exe as a fallback — avoids requiring CredentialManager module $cmdkeyOut = & cmdkey.exe /list:$GuestCredTarget 2>&1 | Out-String if ($cmdkeyOut -imatch [regex]::Escape($GuestCredTarget)) { return $true } # Try via CredentialManager module if available if ($null -ne (Get-Module -Name CredentialManager -ListAvailable -ErrorAction SilentlyContinue)) { Import-Module CredentialManager -ErrorAction SilentlyContinue $cred = Get-StoredCredential -Target $GuestCredTarget -ErrorAction SilentlyContinue return ($null -ne $cred) } return $false } # ── Check 3: CI directory structure ─────────────────────────────────────────── Write-Host "`n=== CI Directory Structure ($CIRootDir) ===" $requiredDirs = @( 'BuildVMs', 'Artifacts', 'Logs', 'State', 'keys', 'Cache\NuGet', 'Cache\pip', 'Templates' ) foreach ($rel in $requiredDirs) { $full = Join-Path $CIRootDir $rel Write-Check "Directory exists: $rel" { Test-Path $full -PathType Container } } # ── Check 4: vmrun.exe reachable ────────────────────────────────────────────── Write-Host "`n=== VMware ===" Write-Check "vmrun.exe found in PATH or default location" { $vmrunDefault = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe' $inPath = $null -ne (Get-Command 'vmrun.exe' -ErrorAction SilentlyContinue) $atDefault = Test-Path $vmrunDefault -PathType Leaf $inPath -or $atDefault } # ── Summary ─────────────────────────────────────────────────────────────────── Write-Host "`n=== Summary ===" Write-Host " Passed : $($script:passCount)" Write-Host " Failed : $($script:failCount)" if ($script:failCount -gt 0) { Write-Host "`n[Validate-HostState] $($script:failCount) check(s) failed. Review output above." -ForegroundColor Red exit 1 } else { Write-Host "`n[Validate-HostState] All checks passed." -ForegroundColor Green exit 0 }