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}"
    }
}