public/Test-Safehouse.ps1
|
# Guerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0 # https://github.com/jimrtyler/Guerrilla | https://creativecommons.org/licenses/by/4.0/ # AI/LLM use: see AI-USAGE.md for required attribution function Test-Safehouse { <# .SYNOPSIS Tests live connectivity for all configured environments. .DESCRIPTION Validates stored credentials by authenticating and making minimal API calls to Google Workspace, Microsoft Graph, and Active Directory. Shows actionable remediation guidance for any failures. .PARAMETER ConfigFile Path to guerrilla-config.json to determine which environments to test. .PARAMETER VaultName Name of the SecretManagement vault. Default: Guerrilla. .EXAMPLE Test-Safehouse -ConfigFile .\guerrilla-config.json #> [CmdletBinding()] param( [Alias('MissionConfig')] [string]$ConfigFile, [string]$VaultName = 'Guerrilla' ) # ── Colors ────────────────────────────────────────────────────────────── $amber = $script:Palette.Amber $green = $script:Palette.Sage $red = $script:Palette.Red $white = $script:Palette.Parchment $khaki = $script:Palette.Khaki $reset = $PSStyle.Reset $results = [System.Collections.Generic.List[PSCustomObject]]::new() $script:passCount = 0 $script:totalCount = 0 # ── Resolve config ────────────────────────────────────────────────────── $missionCfg = $null $enabledEnvs = @{} if ($ConfigFile) { $missionCfg = Read-MissionConfig -Path $ConfigFile $enabledEnvs = $missionCfg.EnabledEnvironments $vaultName = $missionCfg.VaultName } # ── Helper: Write a test result line ──────────────────────────────────── function Write-TestResult { param( [string]$Name, [string]$Status, [long]$ElapsedMs, [string]$Detail, [string]$Environment, [string[]]$Remediation ) $statusColor = if ($Status -in @('CONNECTED', 'VALID', 'STORED', 'KERBEROS')) { $green } else { $red } $statusIcon = if ($Status -in @('CONNECTED', 'VALID', 'STORED', 'KERBEROS')) { [char]0x2713 } else { [char]0x2717 } $elapsed = "${ElapsedMs}ms" $nameShort = if ($Name.Length -gt 24) { $Name.Substring(0, 21) + '...' } else { $Name } $detailShort = if ($Detail.Length -gt 30) { $Detail.Substring(0, 27) + '...' } else { $Detail } Write-Host " ${statusColor}${statusIcon} $(($nameShort).PadRight(25)) $(($Status).PadRight(12)) $(($elapsed).PadRight(8)) ${detailShort}${reset}" if ($Remediation -and $Status -notin @('CONNECTED', 'VALID', 'STORED', 'KERBEROS')) { foreach ($fix in $Remediation) { Write-Host " ${amber}$([char]0x21B3) ${fix}${reset}" } } $script:totalCount++ if ($Status -in @('CONNECTED', 'VALID', 'STORED', 'KERBEROS')) { $script:passCount++ } $results.Add([PSCustomObject]@{ Environment = $Environment Name = $Name Status = $Status Detail = $Detail ElapsedMs = $ElapsedMs }) } # ── Header ────────────────────────────────────────────────────────────── Write-Host '' Write-Host " ${white}SAFEHOUSE CONNECTIVITY TEST${reset}" Write-Host " ${khaki}$("$([char]0x2500)" * 58)${reset}" # ════════════════════════════════════════════════════════════════════════ # GOOGLE WORKSPACE # ════════════════════════════════════════════════════════════════════════ $testGws = if ($missionCfg) { $enabledEnvs.ContainsKey('googleWorkspace') } else { $true } if ($testGws) { Write-Host '' Write-Host " ${white}Google Workspace${reset}" $gwsRef = if ($missionCfg) { $missionCfg.Config.credentials.references.googleWorkspace } else { $null } $saVaultKey = if ($gwsRef) { $gwsRef.vaultKey } else { 'GUERRILLA_GWS_SA' } $adminEmailKey = "${saVaultKey}_ADMIN_EMAIL" # Step 1: Service Account JSON $saJson = $null $sw = [System.Diagnostics.Stopwatch]::StartNew() try { $saJson = Get-GuerrillaCredential -VaultKey $saVaultKey -VaultName $VaultName $sa = $saJson | ConvertFrom-Json $sw.Stop() Write-TestResult -Name 'Service Account JSON' -Status 'STORED' -ElapsedMs $sw.ElapsedMilliseconds ` -Detail $sa.client_email -Environment 'Google Workspace' } catch { $sw.Stop() Write-TestResult -Name 'Service Account JSON' -Status 'MISSING' -ElapsedMs $sw.ElapsedMilliseconds ` -Detail '' -Environment 'Google Workspace' ` -Remediation @('Run Set-Safehouse to store your service account key') } # Step 2: Admin Email $adminEmail = $null $sw = [System.Diagnostics.Stopwatch]::StartNew() try { $adminEmail = Get-GuerrillaCredential -VaultKey $adminEmailKey -VaultName $VaultName $sw.Stop() Write-TestResult -Name 'Admin Email' -Status 'STORED' -ElapsedMs $sw.ElapsedMilliseconds ` -Detail $adminEmail -Environment 'Google Workspace' } catch { $sw.Stop() Write-TestResult -Name 'Admin Email' -Status 'MISSING' -ElapsedMs $sw.ElapsedMilliseconds ` -Detail '' -Environment 'Google Workspace' ` -Remediation @( 'Run Set-Safehouse to store admin email' 'Must be a Super Admin in your Workspace domain' ) } # Step 3: Authentication if ($saJson -and $adminEmail) { $tempSaPath = $null try { $tempSaPath = Join-Path ([System.IO.Path]::GetTempPath()) "guerrilla-test-sa-$([guid]::NewGuid().ToString('N').Substring(0,8)).json" $saJson | Set-Content -Path $tempSaPath -Encoding UTF8 -NoNewline $sw = [System.Diagnostics.Stopwatch]::StartNew() $accessToken = Get-GoogleAccessToken -ServiceAccountKeyPath $tempSaPath ` -AdminEmail $adminEmail ` -Scopes @('https://www.googleapis.com/auth/admin.directory.user.readonly') ` -ForceRefresh $sw.Stop() Write-TestResult -Name 'Authentication' -Status 'CONNECTED' -ElapsedMs $sw.ElapsedMilliseconds ` -Detail 'Token acquired' -Environment 'Google Workspace' # Step 4: API Access $sw = [System.Diagnostics.Stopwatch]::StartNew() try { $userCheck = Invoke-GoogleAdminApi -AccessToken $accessToken ` -Uri 'https://admin.googleapis.com/admin/directory/v1/users' ` -QueryParameters @{ customer = 'my_customer'; maxResults = '1' } -Quiet $sw.Stop() $tenantDomain = if ($userCheck.users -and $userCheck.users[0].primaryEmail) { ($userCheck.users[0].primaryEmail -split '@')[1] } else { 'OK' } Write-TestResult -Name 'API Access' -Status 'CONNECTED' -ElapsedMs $sw.ElapsedMilliseconds ` -Detail $tenantDomain -Environment 'Google Workspace' } catch { $sw.Stop() $errMsg = $_.Exception.Message $fixes = @('Service account may be missing required scopes in domain-wide delegation') if ($errMsg -match '403') { $clientId = $null try { $clientId = ($saJson | ConvertFrom-Json).client_id } catch {} $fixes = @( 'Missing scopes in domain-wide delegation' 'admin.google.com > Security > API controls > Manage Domain Wide Delegation' ) if ($clientId) { $fixes += "Client ID: $clientId" } $fixes += 'Required scopes: admin.directory.*, gmail.*, apps.alerts, chrome.management.policy.readonly' } Write-TestResult -Name 'API Access' -Status 'FAILED' -ElapsedMs $sw.ElapsedMilliseconds ` -Detail ($errMsg.Substring(0, [Math]::Min(60, $errMsg.Length))) -Environment 'Google Workspace' ` -Remediation $fixes } } catch { $sw.Stop() $errMsg = "$($_.Exception.Message)" $fixes = @() if ($errMsg -match 'unauthorized_client') { $clientId = $null try { $clientId = ($saJson | ConvertFrom-Json).client_id } catch {} $fixes = @( 'Domain-wide delegation not configured or not yet propagated' 'admin.google.com > Security > API controls > Manage Domain Wide Delegation' ) if ($clientId) { $fixes += "Add Client ID: $clientId with required OAuth scopes" } $fixes += 'Changes may take up to 1 hour to propagate' } elseif ($errMsg -match 'invalid_grant') { $fixes = @( 'Verify admin email is a Super Admin in your Workspace domain' 'admin.google.com > Directory > Users > check admin role' ) } else { $fixes = @("Error: $($errMsg.Substring(0, [Math]::Min(80, $errMsg.Length)))") } Write-TestResult -Name 'Authentication' -Status 'FAILED' -ElapsedMs $sw.ElapsedMilliseconds ` -Detail 'Token request failed' -Environment 'Google Workspace' ` -Remediation $fixes } finally { if ($tempSaPath -and (Test-Path $tempSaPath)) { Remove-Item -Path $tempSaPath -Force -ErrorAction SilentlyContinue } } } } # ════════════════════════════════════════════════════════════════════════ # MICROSOFT CLOUD (Graph + ARM) # ════════════════════════════════════════════════════════════════════════ $testCloud = if ($missionCfg) { $enabledEnvs.ContainsKey('entraAzure') -or $enabledEnvs.ContainsKey('m365') -or $enabledEnvs.ContainsKey('intune') } else { $true } if ($testCloud) { Write-Host '' Write-Host " ${white}Microsoft Cloud${reset}" $graphRef = if ($missionCfg) { $missionCfg.Config.credentials.references.microsoftGraph } else { $null } $tenantKey = if ($graphRef) { $graphRef.tenantIdVaultKey } else { 'GUERRILLA_GRAPH_TENANT' } $clientKey = if ($graphRef) { $graphRef.clientIdVaultKey } else { 'GUERRILLA_GRAPH_CLIENTID' } $secretKey = if ($graphRef) { $graphRef.vaultKey } else { 'GUERRILLA_GRAPH_SECRET' } # Step 1: Tenant ID $tenantId = $null $sw = [System.Diagnostics.Stopwatch]::StartNew() try { $tenantId = Get-GuerrillaCredential -VaultKey $tenantKey -VaultName $VaultName $sw.Stop() if ($tenantId -match '^[0-9a-fA-F]{8}-') { $display = $tenantId.Substring(0, 13) + '...' Write-TestResult -Name 'Tenant ID' -Status 'VALID' -ElapsedMs $sw.ElapsedMilliseconds ` -Detail $display -Environment 'Microsoft Cloud' } else { Write-TestResult -Name 'Tenant ID' -Status 'INVALID' -ElapsedMs $sw.ElapsedMilliseconds ` -Detail 'Not a valid GUID' -Environment 'Microsoft Cloud' ` -Remediation @('Azure Portal > Microsoft Entra ID > Overview > Tenant ID') } } catch { $sw.Stop() Write-TestResult -Name 'Tenant ID' -Status 'MISSING' -ElapsedMs $sw.ElapsedMilliseconds ` -Detail '' -Environment 'Microsoft Cloud' ` -Remediation @('Run Set-Safehouse to store Tenant ID') } # Step 2: Client ID $clientId = $null $sw = [System.Diagnostics.Stopwatch]::StartNew() try { $clientId = Get-GuerrillaCredential -VaultKey $clientKey -VaultName $VaultName $sw.Stop() if ($clientId -match '^[0-9a-fA-F]{8}-') { $display = $clientId.Substring(0, 13) + '...' Write-TestResult -Name 'Client ID' -Status 'VALID' -ElapsedMs $sw.ElapsedMilliseconds ` -Detail $display -Environment 'Microsoft Cloud' } else { Write-TestResult -Name 'Client ID' -Status 'INVALID' -ElapsedMs $sw.ElapsedMilliseconds ` -Detail 'Not a valid GUID' -Environment 'Microsoft Cloud' ` -Remediation @('Azure Portal > App registrations > your app > Overview') } } catch { $sw.Stop() Write-TestResult -Name 'Client ID' -Status 'MISSING' -ElapsedMs $sw.ElapsedMilliseconds ` -Detail '' -Environment 'Microsoft Cloud' ` -Remediation @('Run Set-Safehouse to store Client ID') } # Step 3: Client Secret $clientSecret = $null $sw = [System.Diagnostics.Stopwatch]::StartNew() try { $secretPlain = Get-GuerrillaCredential -VaultKey $secretKey -VaultName $VaultName $clientSecret = $secretPlain | ConvertTo-SecureString -AsPlainText -Force $sw.Stop() Write-TestResult -Name 'Client Secret' -Status 'STORED' -ElapsedMs $sw.ElapsedMilliseconds ` -Detail '' -Environment 'Microsoft Cloud' } catch { $sw.Stop() Write-TestResult -Name 'Client Secret' -Status 'MISSING' -ElapsedMs $sw.ElapsedMilliseconds ` -Detail '' -Environment 'Microsoft Cloud' ` -Remediation @( 'Run Set-Safehouse to store client secret' 'Copy the VALUE, not the Secret ID' ) } # Step 4: Authentication if ($tenantId -and $clientId -and $clientSecret) { $sw = [System.Diagnostics.Stopwatch]::StartNew() $graphToken = $null try { $graphToken = Get-GraphAccessToken -TenantId $tenantId -ClientId $clientId ` -ClientSecret $clientSecret -ForceRefresh $sw.Stop() Write-TestResult -Name 'Authentication' -Status 'CONNECTED' -ElapsedMs $sw.ElapsedMilliseconds ` -Detail 'Token acquired' -Environment 'Microsoft Cloud' # Step 5: API Access $sw = [System.Diagnostics.Stopwatch]::StartNew() try { $org = Invoke-GraphApi -AccessToken $graphToken -Uri '/organization' -Quiet $sw.Stop() $orgName = if ($org.value -and $org.value[0].displayName) { $org.value[0].displayName } elseif ($org.displayName) { $org.displayName } else { 'OK' } Write-TestResult -Name 'API Access' -Status 'CONNECTED' -ElapsedMs $sw.ElapsedMilliseconds ` -Detail $orgName -Environment 'Microsoft Cloud' } catch { $sw.Stop() $apiErr = "$($_.Exception.Message)" $apiErrShort = if ($apiErr.Length -gt 60) { $apiErr.Substring(0, 57) + '...' } else { $apiErr } Write-TestResult -Name 'API Access' -Status 'FAILED' -ElapsedMs $sw.ElapsedMilliseconds ` -Detail $apiErrShort -Environment 'Microsoft Cloud' ` -Remediation @( 'Add Application permissions (not Delegated) with admin consent:' 'Directory.Read.All, Policy.Read.All, Application.Read.All' 'RoleManagement.Read.All, Reports.Read.All, AuditLog.Read.All' 'DeviceManagementConfiguration.Read.All, DeviceManagementApps.Read.All' 'DeviceManagementManagedDevices.Read.All' 'Azure Portal > App registrations > API permissions > Grant admin consent' ) } } catch { $sw.Stop() $errMsg = "$($_.Exception.Message)" $fixes = @() if ($errMsg -match 'invalid_client' -or $errMsg -match 'AADSTS7000215') { $fixes = @( 'Invalid client secret — you may have stored the Secret ID instead of the Value' 'Azure Portal > App registrations > Certificates & secrets' 'Create a New client secret > copy the VALUE column' ) } elseif ($errMsg -match 'AADSTS90002' -or $errMsg -match 'not found') { $fixes = @( 'Tenant not found — verify Tenant ID' 'Azure Portal > Microsoft Entra ID > Overview > Tenant ID' ) } elseif ($errMsg -match 'AADSTS700016') { $fixes = @( 'Application not found — verify Client ID' 'Azure Portal > App registrations > your app > Overview' ) } else { $fixes = @("Error: $($errMsg.Substring(0, [Math]::Min(100, $errMsg.Length)))") } Write-TestResult -Name 'Authentication' -Status 'FAILED' -ElapsedMs $sw.ElapsedMilliseconds ` -Detail 'Token request failed' -Environment 'Microsoft Cloud' ` -Remediation $fixes } } } # ════════════════════════════════════════════════════════════════════════ # ACTIVE DIRECTORY # ════════════════════════════════════════════════════════════════════════ $testAD = if ($missionCfg) { $enabledEnvs.ContainsKey('activeDirectory') } else { $true } if ($testAD) { Write-Host '' Write-Host " ${white}Active Directory${reset}" $sw = [System.Diagnostics.Stopwatch]::StartNew() try { $domain = [System.DirectoryServices.ActiveDirectory.Domain]::GetCurrentDomain() $sw.Stop() Write-TestResult -Name 'Domain Connectivity' -Status 'CONNECTED' -ElapsedMs $sw.ElapsedMilliseconds ` -Detail $domain.Name -Environment 'Active Directory' } catch { $sw.Stop() $errMsg = "$($_.Exception.Message)" $fixes = @() if ($errMsg -match 'not joined' -or $errMsg -match 'cannot contact') { $fixes = @('This machine is not joined to a domain or cannot reach a DC') } else { $fixes = @( 'Verify domain connectivity and Kerberos ticket' 'Run: klist to check your current tickets' ) } Write-TestResult -Name 'Domain Connectivity' -Status 'FAILED' -ElapsedMs $sw.ElapsedMilliseconds ` -Detail '' -Environment 'Active Directory' ` -Remediation $fixes } } # ── Summary ───────────────────────────────────────────────────────────── Write-Host '' Write-Host " ${khaki}$("$([char]0x2500)" * 58)${reset}" $summaryColor = if ($script:passCount -eq $script:totalCount) { $green } else { $amber } Write-Host " ${summaryColor}Result: $($script:passCount)/$($script:totalCount) checks passed${reset}" Write-Host '' # Return structured results return $results } |