Public/Set-Safehouse.ps1
|
# PSGuerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0 # https://github.com/jimrtyler/PSGuerrilla | https://creativecommons.org/licenses/by/4.0/ # AI/LLM use: see AI-USAGE.md for required attribution function Set-Safehouse { <# .SYNOPSIS Manages PSGuerrilla credential storage and configuration. .DESCRIPTION Set-Safehouse creates and manages the PSGuerrilla SecretManagement vault for secure credential storage. It supports interactive credential setup from a guerrilla-config.json file, vault status display, credential rotation, testing, and non-credential configuration settings. All credentials are encrypted using Microsoft SecretManagement with the SecretStore backend (DPAPI on Windows, encrypted file on Linux/macOS). .PARAMETER ConfigFile Path to a guerrilla-config.json file generated by the PSGuerrilla configuration website. Set-Safehouse reads the credential references and prompts for each one. .PARAMETER VaultName Name of the SecretManagement vault. Default: PSGuerrilla .PARAMETER Force Skip confirmation prompts and auto-install missing modules. .PARAMETER Status Display the current vault status including stored credentials and expiration warnings. .PARAMETER Rotate Rotate one or more credentials by environment name (e.g., Graph, GWS, Teams). .PARAMETER Remove Remove one or more credentials from the vault by environment name. .PARAMETER Test Test connectivity for all stored credentials. .PARAMETER ExportMetadata Export credential metadata (not secrets) to a JSON file for documentation. .PARAMETER Path Output path for ExportMetadata. .PARAMETER OutputDirectory Set the default output directory for reports. .PARAMETER Profile Set the scoring profile (Default or K12). .PARAMETER MinimumAlertLevel Set the minimum threat level for alerts. .PARAMETER ConfigPath Path to the PSGuerrilla runtime config file (config.json). Used with non-credential settings. .EXAMPLE Set-Safehouse -ConfigFile .\guerrilla-config.json # Interactive credential setup from website-generated config .EXAMPLE Set-Safehouse -Status # Display vault status .EXAMPLE Set-Safehouse -Test # Test all stored credentials .EXAMPLE Set-Safehouse -Rotate Graph # Rotate Microsoft Graph credentials .EXAMPLE Set-Safehouse -OutputDirectory C:\Reports -Profile K12 # Update non-credential settings #> [CmdletBinding(DefaultParameterSetName = 'Setup', SupportsShouldProcess, ConfirmImpact = 'Low')] param( # --- Setup parameter set --- [Parameter(ParameterSetName = 'Setup', Position = 0)] [Alias('MissionConfig')] [string]$ConfigFile, [Parameter(ParameterSetName = 'Setup')] [Parameter(ParameterSetName = 'StatusSet')] [Parameter(ParameterSetName = 'TestSet')] [Parameter(ParameterSetName = 'RotateSet')] [Parameter(ParameterSetName = 'RemoveSet')] [Parameter(ParameterSetName = 'ExportMetadataSet')] [string]$VaultName = 'PSGuerrilla', [Parameter(ParameterSetName = 'Setup')] [switch]$Force, # --- Status parameter set --- [Parameter(Mandatory, ParameterSetName = 'StatusSet')] [switch]$Status, # --- Rotate parameter set --- [Parameter(Mandatory, ParameterSetName = 'RotateSet')] [string[]]$Rotate, # --- Remove parameter set --- [Parameter(Mandatory, ParameterSetName = 'RemoveSet')] [string[]]$Remove, # --- Test parameter set --- [Parameter(Mandatory, ParameterSetName = 'TestSet')] [switch]$Test, # --- Export metadata parameter set --- [Parameter(Mandatory, ParameterSetName = 'ExportMetadataSet')] [switch]$ExportMetadata, [Parameter(ParameterSetName = 'ExportMetadataSet')] [string]$Path, # --- Config settings parameter set (non-credential) --- [Parameter(ParameterSetName = 'ConfigSettings')] [string]$OutputDirectory, [Parameter(ParameterSetName = 'ConfigSettings')] [ValidateSet('Default', 'K12')] [string]$Profile, [Parameter(ParameterSetName = 'ConfigSettings')] [ValidateSet('CRITICAL', 'HIGH', 'MEDIUM', 'LOW')] [string]$MinimumAlertLevel, [Parameter(ParameterSetName = 'ConfigSettings')] [bool]$EnableAlerting, [Parameter(ParameterSetName = 'ConfigSettings')] [bool]$EnableSuppression, [Parameter(ParameterSetName = 'ConfigSettings')] [ValidateRange(1, 720)] [int]$SuppressionWindowHours, [Parameter(ParameterSetName = 'ConfigSettings')] [ValidateRange(0, 23)] [int]$BusinessHoursStart, [Parameter(ParameterSetName = 'ConfigSettings')] [ValidateRange(0, 23)] [int]$BusinessHoursEnd, [Parameter(ParameterSetName = 'ConfigSettings')] [string]$BusinessHoursTimezone, [Parameter(ParameterSetName = 'ConfigSettings')] [string[]]$BusinessDays, [Parameter(ParameterSetName = 'ConfigSettings')] [ValidateRange(100, 5000)] [int]$ImpossibleTravelSpeedKmh, [Parameter(ParameterSetName = 'ConfigSettings')] [ValidateRange(1, 60)] [int]$ConcurrentSessionWindowMinutes, [Parameter(ParameterSetName = 'ConfigSettings')] [ValidateRange(2, 100)] [int]$BruteForceFailureThreshold, [Parameter(ParameterSetName = 'ConfigSettings')] [ValidateRange(1, 60)] [int]$BruteForceWindowMinutes, [Parameter(ParameterSetName = 'ConfigSettings')] [ValidateRange(0, 7)] [int]$AutoUpdateIntelDays, [Parameter(ParameterSetName = 'ConfigSettings')] [ValidateRange(10, 1000)] [int]$BulkDownloadThreshold, [Parameter(ParameterSetName = 'ConfigSettings')] [ValidateRange(1, 120)] [int]$BulkDownloadWindowMinutes, [Parameter(ParameterSetName = 'ConfigSettings')] [ValidateRange(10, 1000)] [int]$M365BulkFileThreshold, [Parameter(ParameterSetName = 'ConfigSettings')] [ValidateRange(1, 120)] [int]$M365BulkFileWindowMinutes, [Parameter(ParameterSetName = 'ConfigSettings')] [string[]]$TrustedCountries, [Parameter(ParameterSetName = 'ConfigSettings')] [string[]]$KnownCompromisedUsers, [Parameter(ParameterSetName = 'ConfigSettings')] [string[]]$AdditionalAttackerIps, [Parameter(ParameterSetName = 'ConfigSettings')] [string[]]$AdditionalCloudCidrs, [Parameter(ParameterSetName = 'ConfigSettings')] [string[]]$AdditionalSuspiciousCountries, [Parameter(ParameterSetName = 'ConfigSettings')] [ValidateSet('en-US')] [string]$ReportLanguage, [Parameter(ParameterSetName = 'ConfigSettings')] [string]$ADServer, [Parameter(ParameterSetName = 'ConfigSettings')] [Alias('RuntimeConfig')] [string]$ConfigPath, [Parameter(ParameterSetName = 'ConfigSettings')] [hashtable]$Raw ) $amber = $script:Palette.Amber $green = $script:Palette.Sage $white = $script:Palette.Parchment $khaki = $script:Palette.Khaki $gray = $script:Palette.Gray $reset = $PSStyle.Reset # Gate every mutating branch on -WhatIf / -Confirm so the user can dry-run. # Read-only branches (StatusSet, TestSet) skip the check. $mutatingSets = @('Setup', 'RotateSet', 'RemoveSet', 'ConfigSettings', 'ExportMetadataSet') if ($PSCmdlet.ParameterSetName -in $mutatingSets) { $target = if ($ConfigFile) { $ConfigFile } else { $VaultName } $action = switch ($PSCmdlet.ParameterSetName) { 'Setup' { 'Initialize vault and store credentials' } 'RotateSet' { "Rotate credentials: $($Rotate -join ', ')" } 'RemoveSet' { "Remove credentials: $($Remove -join ', ')" } 'ConfigSettings' { 'Update runtime configuration' } 'ExportMetadataSet' { 'Export vault metadata to disk' } } if (-not $PSCmdlet.ShouldProcess($target, $action)) { return } } switch ($PSCmdlet.ParameterSetName) { 'StatusSet' { Show-SafehouseStatus -VaultName $VaultName return } 'TestSet' { $vaultInfo = Initialize-GuerrillaVault -VaultName $VaultName Test-CredentialConnectivity -VaultName $VaultName return } 'RotateSet' { $vaultInfo = Initialize-GuerrillaVault -VaultName $VaultName $metadata = Get-VaultMetadata -VaultName $VaultName foreach ($target in $Rotate) { $matchingKeys = @($metadata.credentials.Keys | Where-Object { $metadata.credentials[$_].environment -like "*$target*" -or $_ -like "*$($target.ToUpper())*" -or $metadata.credentials[$_].description -like "*$target*" }) if ($matchingKeys.Count -eq 0) { Write-Warning "No credentials found matching '$target'. Run Set-Safehouse -Status to see stored credentials." continue } foreach ($key in $matchingKeys) { $cred = $metadata.credentials[$key] Write-Host " ${amber}Rotating: $($cred.description) ($key)${reset}" $newValue = Read-CredentialValue -PromptType $cred.promptType -Description $cred.description -VaultKey $key if ($newValue) { Set-GuerrillaCredential -VaultKey $key -Value $newValue -VaultName $VaultName $metadata.credentials[$key].storedDate = [datetime]::UtcNow.ToString('o') # Ask for new expiration if applicable if ($cred.expirationDate -or $cred.type -eq 'clientSecret') { $expInput = Read-Host " When does this credential expire? (YYYY-MM-DD, or Enter to skip)" if ($expInput) { $metadata.credentials[$key].expirationDate = $expInput } } Write-Host " ${green}✓ Rotated: $($cred.description)${reset}" } } } Set-VaultMetadata -Metadata $metadata -VaultName $VaultName return } 'RemoveSet' { $vaultInfo = Initialize-GuerrillaVault -VaultName $VaultName $metadata = Get-VaultMetadata -VaultName $VaultName foreach ($target in $Remove) { $matchingKeys = @($metadata.credentials.Keys | Where-Object { $metadata.credentials[$_].environment -like "*$target*" -or $_ -like "*$($target.ToUpper())*" -or $metadata.credentials[$_].description -like "*$target*" }) if ($matchingKeys.Count -eq 0) { Write-Warning "No credentials found matching '$target'." continue } foreach ($key in $matchingKeys) { try { Remove-Secret -Name $key -Vault $VaultName -ErrorAction Stop $metadata.credentials.Remove($key) Write-Host " ${green}✓ Removed: $key${reset}" } catch { Write-Warning "Failed to remove '$key': $_" } } } Set-VaultMetadata -Metadata $metadata -VaultName $VaultName return } 'ExportMetadataSet' { $metadata = Get-VaultMetadata -VaultName $VaultName $exportPath = if ($Path) { $Path } else { Join-Path (Get-Location) 'safehouse-metadata.json' } $metadata | ConvertTo-Json -Depth 10 | Set-Content -Path $exportPath -Encoding UTF8 Write-Host " ${green}✓ Metadata exported to: $exportPath${reset}" return [PSCustomObject]@{ Path = $exportPath; Status = 'Exported' } } 'ConfigSettings' { # Non-credential settings — write to config.json $cfgPath = if ($ConfigPath) { $ConfigPath } else { $script:ConfigPath } $dir = Split-Path $cfgPath -Parent if (-not (Test-Path $dir)) { New-Item -Path $dir -ItemType Directory -Force | Out-Null } if (Test-Path $cfgPath) { $config = Get-Content -Path $cfgPath -Raw | ConvertFrom-Json -AsHashtable } else { $config = @{ output = @{ directory = Join-Path (Get-PSGuerrillaDataRoot) 'Reports' generateCsv = $true generateHtml = $true generateJson = $true } alerting = @{ enabled = $true minimumThreatLevel = 'HIGH' suppression = @{ enabled = $false; windowHours = 24 } } ad = @{ server = '' } detection = @{ knownCompromisedUsers = @() additionalAttackerIps = @() additionalCloudCidrs = @() additionalSuspiciousCountries = @() businessHoursStart = 7 businessHoursEnd = 19 businessHoursTimezone = 'UTC' businessDays = @('Monday','Tuesday','Wednesday','Thursday','Friday') impossibleTravelSpeedKmh = 900 concurrentSessionWindowMinutes = 5 bruteForceFailureThreshold = 5 bruteForceWindowMinutes = 10 autoUpdateIntelDays = 7 bulkDownloadThreshold = 50 bulkDownloadWindowMinutes = 10 m365BulkFileThreshold = 100 m365BulkFileWindowMinutes = 30 trustedCountries = @('US', 'CA', 'GB') } scheduling = @{ taskName = 'PSGuerrilla-Patrol'; intervalMinutes = 60 } } } if ($Raw) { $config = $Raw } else { if ($OutputDirectory) { $config.output.directory = $OutputDirectory } if ($Profile) { $config.profile = $Profile } if ($MinimumAlertLevel) { $config.alerting.minimumThreatLevel = $MinimumAlertLevel } if ($PSBoundParameters.ContainsKey('EnableAlerting')) { $config.alerting.enabled = $EnableAlerting } if ($PSBoundParameters.ContainsKey('EnableSuppression')) { if (-not $config.alerting.suppression) { $config.alerting.suppression = @{ enabled = $false; windowHours = 24 } } $config.alerting.suppression.enabled = $EnableSuppression } if ($PSBoundParameters.ContainsKey('SuppressionWindowHours')) { if (-not $config.alerting.suppression) { $config.alerting.suppression = @{ enabled = $false; windowHours = 24 } } $config.alerting.suppression.windowHours = $SuppressionWindowHours } if ($ADServer) { if (-not $config.ad) { $config.ad = @{ server = '' } } $config.ad.server = $ADServer } if ($PSBoundParameters.ContainsKey('BusinessHoursStart')) { $config.detection.businessHoursStart = $BusinessHoursStart } if ($PSBoundParameters.ContainsKey('BusinessHoursEnd')) { $config.detection.businessHoursEnd = $BusinessHoursEnd } if ($BusinessHoursTimezone) { $config.detection.businessHoursTimezone = $BusinessHoursTimezone } if ($BusinessDays) { $config.detection.businessDays = $BusinessDays } if ($PSBoundParameters.ContainsKey('ImpossibleTravelSpeedKmh')) { $config.detection.impossibleTravelSpeedKmh = $ImpossibleTravelSpeedKmh } if ($PSBoundParameters.ContainsKey('ConcurrentSessionWindowMinutes')) { $config.detection.concurrentSessionWindowMinutes = $ConcurrentSessionWindowMinutes } if ($PSBoundParameters.ContainsKey('BruteForceFailureThreshold')) { $config.detection.bruteForceFailureThreshold = $BruteForceFailureThreshold } if ($PSBoundParameters.ContainsKey('BruteForceWindowMinutes')) { $config.detection.bruteForceWindowMinutes = $BruteForceWindowMinutes } if ($PSBoundParameters.ContainsKey('AutoUpdateIntelDays')) { $config.detection.autoUpdateIntelDays = $AutoUpdateIntelDays } if ($PSBoundParameters.ContainsKey('BulkDownloadThreshold')) { $config.detection.bulkDownloadThreshold = $BulkDownloadThreshold } if ($PSBoundParameters.ContainsKey('BulkDownloadWindowMinutes')) { $config.detection.bulkDownloadWindowMinutes = $BulkDownloadWindowMinutes } if ($PSBoundParameters.ContainsKey('M365BulkFileThreshold')) { $config.detection.m365BulkFileThreshold = $M365BulkFileThreshold } if ($PSBoundParameters.ContainsKey('M365BulkFileWindowMinutes')) { $config.detection.m365BulkFileWindowMinutes = $M365BulkFileWindowMinutes } if ($TrustedCountries) { $config.detection.trustedCountries = $TrustedCountries } if ($KnownCompromisedUsers) { $config.detection.knownCompromisedUsers = $KnownCompromisedUsers } if ($AdditionalAttackerIps) { $config.detection.additionalAttackerIps = $AdditionalAttackerIps } if ($AdditionalCloudCidrs) { $config.detection.additionalCloudCidrs = $AdditionalCloudCidrs } if ($AdditionalSuspiciousCountries) { $config.detection.additionalSuspiciousCountries = $AdditionalSuspiciousCountries } if ($ReportLanguage) { $config.reportLanguage = $ReportLanguage } } $config | ConvertTo-Json -Depth 10 | Set-Content -Path $cfgPath -Encoding UTF8 Write-Verbose "Configuration saved to $cfgPath" return [PSCustomObject]@{ Path = $cfgPath; Status = 'Saved' } } 'Setup' { # --- Main credential setup flow --- $vaultInfo = Initialize-GuerrillaVault -VaultName $VaultName -Force:$Force # Check for existing plaintext credentials in config.json and migrate $cfgPath = $script:ConfigPath if ($cfgPath -and (Test-Path $cfgPath)) { $existingConfig = Get-Content -Path $cfgPath -Raw | ConvertFrom-Json -AsHashtable $hasPlaintextCreds = $false # Check for plaintext credentials if ($existingConfig.google -and $existingConfig.google.serviceAccountKeyPath) { $hasPlaintextCreds = $true } if ($existingConfig.entra -and $existingConfig.entra.clientSecret) { $hasPlaintextCreds = $true } if ($existingConfig.alerting -and $existingConfig.alerting.providers) { foreach ($provKey in $existingConfig.alerting.providers.Keys) { $prov = $existingConfig.alerting.providers[$provKey] if ($prov.apiKey -or $prov.authToken -or $prov.webhookUrl -or $prov.accountSid -or $prov.routingKey) { $hasPlaintextCreds = $true break } } } if ($hasPlaintextCreds -and -not $ConfigFile) { Write-Host '' Write-Host " ${amber}Plaintext credentials detected in config.json${reset}" Write-Host " ${gray}These should be migrated to the encrypted vault.${reset}" $migrateResponse = Read-Host ' Migrate now? [Y/n]' if (-not $migrateResponse -or $migrateResponse -match '^[Yy]') { Invoke-CredentialMigration -Config $existingConfig -VaultName $VaultName -ConfigPath $cfgPath } } } if ($ConfigFile) { # Parse the mission config file $mission = Read-MissionConfig -Path $ConfigFile $credReqs = $mission.CredentialRequirements } else { # Interactive mode — ask the user up front which environments they want # to configure, so we don't march them through credentials for things # they aren't using. $allCredReqs = @{ GoogleWorkspace = @( @{ vaultKey = 'GUERRILLA_GWS_SA'; type = 'serviceAccount'; environment = 'googleWorkspace'; description = 'Google Workspace service account'; promptType = 'serviceAccountJson' } ) Entra = @( @{ vaultKey = 'GUERRILLA_GRAPH_TENANT'; type = 'tenantId'; environment = 'microsoftGraph'; description = 'Entra ID Tenant ID'; promptType = 'guid' } @{ vaultKey = 'GUERRILLA_GRAPH_CLIENTID'; type = 'clientId'; environment = 'microsoftGraph'; description = 'App Registration Client ID'; promptType = 'guid' } @{ vaultKey = 'GUERRILLA_GRAPH_SECRET'; type = 'clientSecret'; environment = 'microsoftGraph'; description = 'Microsoft Graph Client Secret'; promptType = 'secret' } ) } Write-Host '' Write-Host " ${amber}Which environments do you want to set up credentials for?${reset}" Write-Host " ${gray} [1] Google Workspace${reset}" Write-Host " ${gray} [2] Microsoft Entra / Graph / Azure / M365${reset}" Write-Host " ${gray} [3] Active Directory (uses your current Kerberos session — no setup needed)${reset}" Write-Host " ${gray} [A] All of the above${reset}" $sel = Read-Host " Selection (comma-separated, default: A)" if (-not $sel) { $sel = 'A' } $tokens = @(($sel -split '[,\s]+') | Where-Object { $_ } | ForEach-Object { $_.Trim().ToUpper() }) $wantAll = ('A' -in $tokens) -or ('ALL' -in $tokens) $wantGws = $wantAll -or ('1' -in $tokens) -or ('GWS' -in $tokens) -or ('WORKSPACE' -in $tokens) $wantEntra = $wantAll -or ('2' -in $tokens) -or ('ENTRA' -in $tokens) -or ('GRAPH' -in $tokens) -or ('M365' -in $tokens) -or ('AZURE' -in $tokens) $wantAD = $wantAll -or ('3' -in $tokens) -or ('AD' -in $tokens) -or ('ACTIVE' -in $tokens) $credReqs = @() if ($wantGws) { $credReqs += $allCredReqs.GoogleWorkspace } if ($wantEntra) { $credReqs += $allCredReqs.Entra } if ($wantAD) { Write-Host '' Write-Host " ${green}✓ Active Directory will use your current Kerberos session — no credentials stored.${reset}" } } if ($credReqs.Count -eq 0) { Write-Host " ${green}✓ No credential references found in config. Vault is ready.${reset}" return } # Display establishment banner $border = [char]0x2550 $cornerTL = [char]0x2554 $cornerTR = [char]0x2557 $cornerBL = [char]0x255A $cornerBR = [char]0x255D $vertBar = [char]0x2551 $horzDiv = [char]0x2560 $horzDivR = [char]0x2563 $line = "$border" * 60 Write-Host '' Write-Host " ${amber}${cornerTL}${line}${cornerTR}${reset}" Write-Host " ${amber}${vertBar}${reset} ${white}ESTABLISHING SAFEHOUSE${reset}$(' ' * 37)${amber}${vertBar}${reset}" Write-Host " ${amber}${horzDiv}${line}${horzDivR}${reset}" $vaultLine = " Vault: $VaultName (Microsoft SecretStore)".PadRight(60) Write-Host " ${amber}${vertBar}${reset}${khaki}${vaultLine}${reset}${amber}${vertBar}${reset}" $protLine = " Protection: $($vaultInfo.Protection)".PadRight(60) Write-Host " ${amber}${vertBar}${reset}${gray}${protLine}${reset}${amber}${vertBar}${reset}" Write-Host " ${amber}${horzDiv}${line}${horzDivR}${reset}" $countLine = " Credentials Required: $($credReqs.Count)".PadRight(60) Write-Host " ${amber}${vertBar}${reset}${khaki}${countLine}${reset}${amber}${vertBar}${reset}" $emptyLine = ' ' * 60 Write-Host " ${amber}${vertBar}${reset}${emptyLine}${amber}${vertBar}${reset}" # Show pending list for ($i = 0; $i -lt $credReqs.Count; $i++) { $num = "$($i + 1)/$($credReqs.Count)" $pendingLine = " $num $($credReqs[$i].description)".PadRight(52) + 'PENDING ' Write-Host " ${amber}${vertBar}${reset}${gray}${pendingLine}${reset}${amber}${vertBar}${reset}" } Write-Host " ${amber}${cornerBL}${line}${cornerBR}${reset}" Write-Host '' # Prompt for each credential $metadata = Get-VaultMetadata -VaultName $VaultName if (-not $metadata.credentials) { $metadata.credentials = @{} } $storedCount = 0 for ($i = 0; $i -lt $credReqs.Count; $i++) { $req = $credReqs[$i] $num = "$($i + 1)/$($credReqs.Count)" Write-Host " ${white}[$num] $($req.description)${reset}" # Check if already stored $existing = $null try { $existing = Get-Secret -Name $req.vaultKey -Vault $VaultName -ErrorAction Stop } catch {} if ($existing -and -not $Force) { Write-Host " ${green}✓ Already stored ($($req.vaultKey)). Use -Force to overwrite.${reset}" $storedCount++ Write-Host '' continue } $value = Read-CredentialValue -PromptType $req.promptType -Description $req.description -VaultKey $req.vaultKey if ($null -eq $value) { Write-Host " ${gray}— Skipped${reset}" Write-Host '' continue } Set-GuerrillaCredential -VaultKey $req.vaultKey -Value $value -VaultName $VaultName # Build metadata entry $metaEntry = @{ type = $req.type environment = $req.environment storedDate = [datetime]::UtcNow.ToString('o') description = $req.description } # For service accounts, extract identity and prompt for admin email if ($req.promptType -eq 'serviceAccountJson' -and $value) { try { $plainVal = if ($value -is [securestring]) { [System.Net.NetworkCredential]::new('', $value).Password } else { "$value" } $sa = $plainVal | ConvertFrom-Json if ($sa.client_email) { $metaEntry.identity = $sa.client_email } } catch {} # Prompt for the admin email used for domain-wide delegation $adminEmailKey = "$($req.vaultKey)_ADMIN_EMAIL" $existingAdmin = $null try { $existingAdmin = Get-Secret -Name $adminEmailKey -Vault $VaultName -AsPlainText -ErrorAction Stop } catch {} if ($existingAdmin -and -not $Force) { Write-Host " ${green}✓ Admin email already stored.${reset}" } else { Write-Host " ${gray}This must be a Super Admin in your Google Workspace domain.${reset}" Write-Host " ${gray}Admin Console (admin.google.com) > Directory > Users > verify Super Admin role${reset}" Write-Host " Enter the Google Workspace admin email for domain-wide delegation:" $adminEmail = Read-Host ' ' if ($adminEmail) { Set-GuerrillaCredential -VaultKey $adminEmailKey -Value $adminEmail -VaultName $VaultName } } # Show domain-wide delegation setup reminder $clientId = $null try { $clientId = $sa.client_id } catch {} Write-Host '' Write-Host " ${amber}IMPORTANT: Configure domain-wide delegation in Google Admin Console:${reset}" Write-Host " ${gray}admin.google.com > Security > Access and data control > API controls${reset}" Write-Host " ${gray}> Manage Domain Wide Delegation > Add new${reset}" if ($clientId) { Write-Host " ${gray}Client ID: ${clientId}${reset}" } Write-Host " ${gray}Add the following OAuth scopes (comma-separated):${reset}" Write-Host " ${gray} https://www.googleapis.com/auth/admin.directory.user.readonly${reset}" Write-Host " ${gray} https://www.googleapis.com/auth/admin.directory.domain.readonly${reset}" Write-Host " ${gray} https://www.googleapis.com/auth/admin.directory.orgunit.readonly${reset}" Write-Host " ${gray} https://www.googleapis.com/auth/admin.directory.group.readonly${reset}" Write-Host " ${gray} https://www.googleapis.com/auth/admin.directory.customer.readonly${reset}" Write-Host " ${gray} https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly${reset}" Write-Host " ${gray} https://www.googleapis.com/auth/admin.directory.device.mobile.readonly${reset}" Write-Host " ${gray} https://www.googleapis.com/auth/admin.directory.device.chromeos.readonly${reset}" Write-Host " ${gray} https://www.googleapis.com/auth/admin.reports.audit.readonly${reset}" Write-Host " ${gray} https://www.googleapis.com/auth/gmail.settings.basic${reset}" Write-Host " ${gray} https://www.googleapis.com/auth/gmail.readonly${reset}" Write-Host " ${gray} https://www.googleapis.com/auth/apps.alerts${reset}" Write-Host " ${gray} https://www.googleapis.com/auth/chrome.management.policy.readonly${reset}" Write-Host " ${gray}Changes may take up to 1 hour to propagate.${reset}" Write-Host '' } # For GUIDs, store masked identity if ($req.promptType -eq 'guid' -and $value) { $plainVal = if ($value -is [securestring]) { [System.Net.NetworkCredential]::new('', $value).Password } else { "$value" } $metaEntry.identity = $plainVal } # Ask for expiration on client secrets if ($req.type -eq 'clientSecret') { $expInput = Read-Host " When does this secret expire? (YYYY-MM-DD, or Enter to skip)" if ($expInput) { $metaEntry.expirationDate = $expInput } $metaEntry.promptType = 'secret' } $metadata.credentials[$req.vaultKey] = $metaEntry $storedCount++ Write-Host " ${green}✓ $($req.description) — SECURED${reset}" Write-Host '' } # Handle AD (Kerberos auto-detect) if ($ConfigFile) { $missionConfig = $mission.Config if ($missionConfig.credentials -and $missionConfig.credentials.references -and $missionConfig.credentials.references.activeDirectory -and $missionConfig.credentials.references.activeDirectory.type -eq 'currentUser') { Write-Host " ${green}✓ Active Directory — Using current Kerberos ticket (no stored credential)${reset}" Write-Host '' } } Set-VaultMetadata -Metadata $metadata -VaultName $VaultName # Final status Write-Host " ${amber}${cornerTL}${line}${cornerTR}${reset}" Write-Host " ${amber}${vertBar}${reset} ${white}SAFEHOUSE ESTABLISHED${reset}$(' ' * 38)${amber}${vertBar}${reset}" Write-Host " ${amber}${horzDiv}${line}${horzDivR}${reset}" $vLine = " Vault: $VaultName".PadRight(60) Write-Host " ${amber}${vertBar}${reset}${khaki}${vLine}${reset}${amber}${vertBar}${reset}" $bLine = " Backend: Microsoft SecretStore ($($vaultInfo.Protection))".PadRight(60) Write-Host " ${amber}${vertBar}${reset}${gray}${bLine}${reset}${amber}${vertBar}${reset}" $cLine = " Credentials Stored: $storedCount".PadRight(60) Write-Host " ${amber}${vertBar}${reset}${khaki}${cLine}${reset}${amber}${vertBar}${reset}" Write-Host " ${amber}${horzDiv}${line}${horzDivR}${reset}" Write-Host " ${amber}${vertBar}${reset}${emptyLine}${amber}${vertBar}${reset}" $cmdLine = if ($ConfigFile) { " Invoke-Campaign -ConfigFile $ConfigFile" } else { ' Invoke-Campaign' } $instructLine = ' Your safehouse is operational. Run your first scan:'.PadRight(60) Write-Host " ${amber}${vertBar}${reset}${khaki}${instructLine}${reset}${amber}${vertBar}${reset}" Write-Host " ${amber}${vertBar}${reset}${emptyLine}${amber}${vertBar}${reset}" $cmdPadded = $cmdLine.PadRight(60) Write-Host " ${amber}${vertBar}${reset}${white}${cmdPadded}${reset}${amber}${vertBar}${reset}" Write-Host " ${amber}${vertBar}${reset}${emptyLine}${amber}${vertBar}${reset}" Write-Host " ${amber}${cornerBL}${line}${cornerBR}${reset}" Write-Host '' } } } # --- Helper: Read a credential value based on type --- function Read-CredentialValue { [CmdletBinding()] param( [string]$PromptType, [string]$Description, [string]$VaultKey ) $gray = $script:Palette.Gray $amber = $script:Palette.Amber $reset = $PSStyle.Reset switch ($PromptType) { 'serviceAccountJson' { Write-Host " ${gray}Google Cloud Console > IAM & Admin > Service Accounts${reset}" Write-Host " ${gray}Select your service account > Keys tab > Add Key > Create new key (JSON)${reset}" Write-Host " ${gray}Then enable Domain-wide Delegation under Advanced Settings${reset}" Write-Host '' Write-Host " Enter the path to your Google Workspace service account JSON" Write-Host " key file, or paste the JSON content directly:" $userInput = Read-Host ' ' if (-not $userInput) { return $null } # Check if it's a file path if (Test-Path $userInput) { $jsonContent = Get-Content -Path $userInput -Raw try { $sa = $jsonContent | ConvertFrom-Json if ($sa.type -ne 'service_account' -or -not $sa.client_email -or -not $sa.private_key) { Write-Warning 'File does not appear to be a valid service account JSON (missing client_email, private_key, or type).' return $null } Write-Host " Loaded: $($sa.client_email)" $deleteResponse = Read-Host " Delete the original key file now that it's in the vault? [Y/n]" if (-not $deleteResponse -or $deleteResponse -match '^[Yy]') { Remove-Item -Path $userInput -Force Write-Host " Original file deleted." } return $jsonContent } catch { Write-Warning "Failed to parse JSON file: $_" return $null } } else { # Treat as pasted JSON try { $sa = $userInput | ConvertFrom-Json if ($sa.type -ne 'service_account' -or -not $sa.client_email) { Write-Warning 'Input does not appear to be valid service account JSON.' return $null } return $userInput } catch { Write-Warning "Failed to parse JSON input: $_" return $null } } } 'guid' { if ($VaultKey -match 'TENANT') { Write-Host " ${gray}Azure Portal > Microsoft Entra ID > Overview > Tenant ID${reset}" } elseif ($VaultKey -match 'CLIENTID') { Write-Host " ${gray}Azure Portal > Microsoft Entra ID > App registrations > your app > Overview${reset}" Write-Host " ${gray}Copy the Application (client) ID value${reset}" Write-Host '' Write-Host " ${amber}IMPORTANT: Add these Application permissions (not Delegated) and grant admin consent:${reset}" Write-Host " ${gray} Directory.Read.All Policy.Read.All${reset}" Write-Host " ${gray} Application.Read.All RoleManagement.Read.All${reset}" Write-Host " ${gray} Reports.Read.All AuditLog.Read.All${reset}" Write-Host " ${gray} DeviceManagementConfiguration.Read.All DeviceManagementApps.Read.All${reset}" Write-Host " ${gray} DeviceManagementManagedDevices.Read.All${reset}" Write-Host " ${gray}Azure Portal > App registrations > API permissions > Add a permission > Microsoft Graph${reset}" Write-Host " ${gray}> Application permissions > select each above > Add permissions${reset}" Write-Host " ${gray}> Grant admin consent for [your tenant]${reset}" Write-Host '' Write-Host " ${gray}For Azure resource audits, also assign Reader role on your Azure subscription:${reset}" Write-Host " ${gray}Azure Portal > Subscriptions > Access control (IAM) > Add role assignment > Reader${reset}" Write-Host '' } $val = Read-Host " Enter $Description (GUID format)" if (-not $val) { return $null } if ($val -notmatch '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { Write-Warning 'Invalid GUID format. Expected: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' return $null } return $val } 'secret' { if ($VaultKey -match 'GRAPH_SECRET') { Write-Host " ${gray}Azure Portal > App registrations > your app > Certificates & secrets${reset}" Write-Host " ${gray}Create a New client secret > copy the VALUE column (not the Secret ID!)${reset}" Write-Host " ${gray}The Value is only shown once — if you navigated away, create a new secret.${reset}" } $secure = Read-Host " Enter $Description" -AsSecureString if ($secure.Length -eq 0) { return $null } return $secure } 'certificateThumbprint' { $val = Read-Host " Enter certificate thumbprint (40-char hex)" if (-not $val) { return $null } if ($val -notmatch '^[0-9a-fA-F]{40}$') { Write-Warning 'Invalid thumbprint format. Expected 40-character hex string.' return $null } return $val } 'url' { $val = Read-Host " Enter $Description" if (-not $val) { return $null } if ($val -notmatch '^https?://') { Write-Warning 'URL should start with https://' return $null } return $val } 'psCredential' { Write-Host " Enter credentials for $Description" $cred = Get-Credential -Message $Description if (-not $cred) { return $null } return $cred } 'emailConfig' { Write-Host " Enter email configuration for $Description" Write-Host '' Write-Host " ${gray}Which email provider?${reset}" Write-Host " ${gray} 1) Mailgun${reset}" Write-Host " ${gray} 2) SendGrid${reset}" $provChoice = Read-Host ' Selection [1]' if (-not $provChoice -or $provChoice -eq '1') { $provChoice = 'mailgun' } else { $provChoice = 'sendgrid' } $apiKey = Read-Host " API Key ($provChoice)" -AsSecureString if ($apiKey.Length -eq 0) { return $null } $fromEmail = Read-Host ' From email address' $toEmails = Read-Host ' To email addresses (comma-separated)' $emailCfg = @{ provider = $provChoice apiKey = [System.Net.NetworkCredential]::new('', $apiKey).Password fromEmail = $fromEmail toEmails = ($toEmails -split ',').Trim() } if ($provChoice -eq 'mailgun') { # Auto-detect domain from the from address $detectedDomain = if ($fromEmail -match '@(.+)$') { $Matches[1] } else { '' } if ($detectedDomain) { Write-Host " ${gray}Mailgun sending domain (detected: $detectedDomain)${reset}" $domainInput = Read-Host " Domain [$detectedDomain]" if (-not $domainInput) { $domainInput = $detectedDomain } } else { $domainInput = Read-Host ' Mailgun sending domain (e.g., mg.yourdomain.com)' } $emailCfg.domain = $domainInput Write-Host " ${gray}Mailgun dashboard: https://app.mailgun.com/mg/sending/domains${reset}" } return ($emailCfg | ConvertTo-Json -Compress) } 'pushoverConfig' { Write-Host " Enter Pushover configuration for $Description" Write-Host " ${gray}pushover.net > Your Applications > Create an Application/API Token${reset}" $poToken = Read-Host ' Application API Token' -AsSecureString if ($poToken.Length -eq 0) { return $null } Write-Host " ${gray}Your user key is shown on the Pushover dashboard after login${reset}" $poUser = Read-Host ' User Key (or Group Key)' if (-not $poUser) { return $null } $poCfg = @{ apiToken = [System.Net.NetworkCredential]::new('', $poToken).Password userKey = $poUser } return ($poCfg | ConvertTo-Json -Compress) } 'twilioConfig' { Write-Host " Enter Twilio configuration for $Description" $sid = Read-Host ' Account SID' $token = Read-Host ' Auth Token' -AsSecureString $fromNum = Read-Host ' From phone number (e.g., +1234567890)' $toNums = Read-Host ' To phone numbers (comma-separated)' $config = @{ accountSid = $sid authToken = [System.Net.NetworkCredential]::new('', $token).Password fromNumber = $fromNum toNumbers = ($toNums -split ',').Trim() } | ConvertTo-Json -Compress return $config } default { $val = Read-Host " Enter value for $Description" if (-not $val) { return $null } return $val } } } # --- Helper: Migrate plaintext credentials from config.json to vault --- function Invoke-CredentialMigration { [CmdletBinding()] param( [hashtable]$Config, [string]$VaultName, [Alias('RuntimeConfig')] [string]$ConfigPath ) $green = $script:Palette.Sage $gray = $script:Palette.Gray $reset = $PSStyle.Reset $metadata = Get-VaultMetadata -VaultName $VaultName if (-not $metadata.credentials) { $metadata.credentials = @{} } $migrated = 0 # Migrate Google SA key path if ($Config.google -and $Config.google.serviceAccountKeyPath -and (Test-Path $Config.google.serviceAccountKeyPath)) { $saContent = Get-Content -Path $Config.google.serviceAccountKeyPath -Raw Set-GuerrillaCredential -VaultKey 'GUERRILLA_GWS_SA' -Value $saContent -VaultName $VaultName $sa = $saContent | ConvertFrom-Json $metadata.credentials['GUERRILLA_GWS_SA'] = @{ type = 'serviceAccount'; environment = 'googleWorkspace' storedDate = [datetime]::UtcNow.ToString('o') description = 'Google Workspace service account' identity = $sa.client_email } $Config.google.Remove('serviceAccountKeyPath') Write-Host " ${green}✓ Migrated: Google Workspace service account${reset}" $migrated++ } # Migrate Entra credentials if ($Config.entra) { if ($Config.entra.tenantId) { Set-GuerrillaCredential -VaultKey 'GUERRILLA_GRAPH_TENANT' -Value $Config.entra.tenantId -VaultName $VaultName $metadata.credentials['GUERRILLA_GRAPH_TENANT'] = @{ type = 'tenantId'; environment = 'microsoftGraph' storedDate = [datetime]::UtcNow.ToString('o'); description = 'Entra ID Tenant ID' identity = $Config.entra.tenantId } $Config.entra.tenantId = '' $migrated++ } if ($Config.entra.clientId) { Set-GuerrillaCredential -VaultKey 'GUERRILLA_GRAPH_CLIENTID' -Value $Config.entra.clientId -VaultName $VaultName $metadata.credentials['GUERRILLA_GRAPH_CLIENTID'] = @{ type = 'clientId'; environment = 'microsoftGraph' storedDate = [datetime]::UtcNow.ToString('o'); description = 'App Registration Client ID' identity = $Config.entra.clientId } $Config.entra.clientId = '' $migrated++ } if ($Config.entra.clientSecret) { Set-GuerrillaCredential -VaultKey 'GUERRILLA_GRAPH_SECRET' -Value $Config.entra.clientSecret -VaultName $VaultName $metadata.credentials['GUERRILLA_GRAPH_SECRET'] = @{ type = 'clientSecret'; environment = 'microsoftGraph' storedDate = [datetime]::UtcNow.ToString('o'); description = 'Microsoft Graph Client Secret' } $Config.entra.clientSecret = '' $migrated++ } Write-Host " ${green}✓ Migrated: Microsoft Graph credentials${reset}" } # Migrate alerting provider credentials if ($Config.alerting -and $Config.alerting.providers) { $provs = $Config.alerting.providers if ($provs.teams -and $provs.teams.webhookUrl) { Set-GuerrillaCredential -VaultKey 'GUERRILLA_TEAMS_WEBHOOK' -Value $provs.teams.webhookUrl -VaultName $VaultName $metadata.credentials['GUERRILLA_TEAMS_WEBHOOK'] = @{ type = 'webhook'; environment = 'alerting' storedDate = [datetime]::UtcNow.ToString('o'); description = 'Microsoft Teams webhook URL' } $provs.teams.webhookUrl = '' $migrated++ } if ($provs.slack -and $provs.slack.webhookUrl) { Set-GuerrillaCredential -VaultKey 'GUERRILLA_SLACK_WEBHOOK' -Value $provs.slack.webhookUrl -VaultName $VaultName $metadata.credentials['GUERRILLA_SLACK_WEBHOOK'] = @{ type = 'webhook'; environment = 'alerting' storedDate = [datetime]::UtcNow.ToString('o'); description = 'Slack webhook URL' } $provs.slack.webhookUrl = '' $migrated++ } if ($provs.sendgrid -and $provs.sendgrid.apiKey) { Set-GuerrillaCredential -VaultKey 'GUERRILLA_SENDGRID_KEY' -Value $provs.sendgrid.apiKey -VaultName $VaultName $metadata.credentials['GUERRILLA_SENDGRID_KEY'] = @{ type = 'apiKey'; environment = 'alerting' storedDate = [datetime]::UtcNow.ToString('o'); description = 'SendGrid API key' } $provs.sendgrid.apiKey = '' $migrated++ } if ($provs.mailgun -and $provs.mailgun.apiKey) { $mgCred = @{ provider = 'mailgun' apiKey = $provs.mailgun.apiKey domain = $provs.mailgun.domain fromEmail = $provs.mailgun.fromEmail toEmails = $provs.mailgun.toEmails } | ConvertTo-Json -Compress Set-GuerrillaCredential -VaultKey 'GUERRILLA_MAILGUN_KEY' -Value $mgCred -VaultName $VaultName $metadata.credentials['GUERRILLA_MAILGUN_KEY'] = @{ type = 'emailConfig'; environment = 'alerting' storedDate = [datetime]::UtcNow.ToString('o'); description = 'Mailgun email configuration' } $provs.mailgun.apiKey = '' $migrated++ Write-Host " ${green}✓ Migrated: Mailgun credentials${reset}" } if ($provs.pagerduty -and $provs.pagerduty.routingKey) { Set-GuerrillaCredential -VaultKey 'GUERRILLA_PAGERDUTY_KEY' -Value $provs.pagerduty.routingKey -VaultName $VaultName $metadata.credentials['GUERRILLA_PAGERDUTY_KEY'] = @{ type = 'routingKey'; environment = 'alerting' storedDate = [datetime]::UtcNow.ToString('o'); description = 'PagerDuty routing key' } $provs.pagerduty.routingKey = '' $migrated++ } } if ($migrated -gt 0) { Set-VaultMetadata -Metadata $metadata -VaultName $VaultName # Save cleaned config $Config | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigPath -Encoding UTF8 Write-Host " ${green}✓ Migrated $migrated credential(s) to vault. Plaintext removed from config.json${reset}" } else { Write-Host " ${gray}No credentials to migrate.${reset}" } } |