diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..99f1345 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,51 @@ +name: Test + +on: + push: + branches: [root] + pull_request: + branches: [root] + workflow_dispatch: + +defaults: + run: + shell: pwsh + +jobs: + test: + name: Validate + runs-on: windows-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + + - name: Install + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module -Name Pester -Scope CurrentUser + Install-Module -Name PSScriptAnalyzer -Scope CurrentUser + + - name: Lint + run: | + $excludedRules = @( + 'PSAvoidUsingConvertToSecureStringWithPlainText' + ) + $results = Invoke-ScriptAnalyzer -ExcludeRule $excludedRules -Path .\rcgmsa.ps1 -Severity Error + + if ($results) { + $results | Format-Table + Write-Error 'PSScriptAnalyzer found issues. Please fix them.' -ErrorAction Stop + } else { + Write-Host 'PSScriptAnalyzer passed.' + } + + - name: Test + run: | + $config = New-PesterConfiguration + $config.Output.Verbosity = 'Detailed' + $config.Run.Exit = $true + $config.Run.Path = '.\tests' + $config.Should.ErrorAction = 'Continue' + $config.TestResult.Enabled = $true + + Invoke-Pester -Configuration $config diff --git a/rcgmsa.ps1 b/rcgmsa.ps1 index 15841bb..5e361fb 100644 --- a/rcgmsa.ps1 +++ b/rcgmsa.ps1 @@ -62,14 +62,16 @@ if ($v) { $account = (New-Object System.Management.Automation.PSCredential("$Domain\$User")) $command = $Command -replace 'javaopts\=', 'javaopts ' +$credential = $null if ($Raw) { $sb = $command } else { $sb = [scriptblock]::Create($command) } +$keeperFiles = $null $scriptPath = $MyInvocation.MyCommand.Path $serverName = $env:COMPUTERNAME -$sessionOptions = New-PssessionOption -NoCompression +$sessionOptions = New-PSSessionOption -NoCompression $sysTempDir = [System.IO.Path]::GetTempPath() $tempDir = Join-Path -Path $sysTempDir -ChildPath ([Guid]::NewGuid().ToString()) @@ -86,8 +88,8 @@ Write-Host "******************************************************************** if ($Keeper) { $requiredModules = @( - 'Microsoft.PowerShell.SecretStore', 'Microsoft.PowerShell.SecretManagement', + 'Microsoft.PowerShell.SecretStore', 'SecretManagement.Keeper' ) @@ -107,11 +109,11 @@ if ($Keeper) { $vault_credential = [System.Environment]::GetEnvironmentVariable("VAULT", [System.EnvironmentVariableTarget]::User) $password = ConvertTo-SecureString -String $vault_credential -AsPlainText -Force Unlock-SecretStore -Password $password - $credential = Get-Secret -Vault $Vault $Keeper -AsPlainText -ErrorAction Stop + $credential = Get-Secret -Vault $Vault -Name $Keeper -AsPlainText -ErrorAction Stop if ('Files' -in $credential.Keys) { foreach ($filename in ($credential.Files)) { - $data = Get-Secret -Vault $Vault $Keeper $credential.Files[$filename] + $data = Get-Secret -Vault $Vault -Name "$Keeper.Files[$filename]" $filepath = Join-Path -Path $tempDir -ChildPath $filename Set-Content -Path $filepath -Value $data -AsByteStream } @@ -120,8 +122,8 @@ if ($Keeper) { } } catch [Microsoft.PowerShell.SecretManagement.PasswordRequiredException] { Write-Host "Unlocking Vault $Vault for one hour." - Unlock-SecretStore -Password $vault_credential - $credential = Get-Secret -Vault $Vault $Keeper -AsPlainText -ErrorAction Stop + Unlock-SecretStore -Password $password + $credential = Get-Secret -Vault $Vault -Name $Keeper -AsPlainText -ErrorAction Stop } catch [System.Exception] { Write-Error "Failed to retrieve Keeper secret.`nError: $($_.Exception.Message)" $host.SetShouldExit(1) @@ -133,22 +135,23 @@ $parameters = @{ ComputerName = $Computers Credential = $account ScriptBlock = { + $cred = $using:credential $remoteSysTempDir = [System.IO.Path]::GetTempPath() $remoteTempDir = Join-Path -Path $remoteSysTempDir -ChildPath ([Guid]::NewGuid().ToString()) New-Item -Path $remoteTempDir -ItemType Directory -Force | Out-Null - if ($credential) { - foreach ($field in ($credential.Keys)) { + if ($cred) { + foreach ($field in ($cred.Keys)) { if ($field -ne 'Files') { $name = "KEEPER_$($field.ToUpper())" [Environment]::SetEnvironmentVariable( $name, - $credential[$field], + $cred[$field], [System.EnvironmentVariableTarget]::User ) } else { - foreach ($filename in $keeperFiles) { + foreach ($filename in $using:keeperFiles) { Copy-Item -Path $filename.FullName -Destination $remoteTempDir } } @@ -156,7 +159,13 @@ $parameters = @{ } if ($using:Orbs) { - Invoke-Command -ComputerName $using:Orbs -Credential $using:account -ScriptBlock { & $using:sb } -SessionOption $using:sessionOptions + $orbParameters = @{ + ComputerName = $using:Orbs + Credential = $using:account + ScriptBlock = { & $using:sb } + SessionOption = $using:sessionOptions + } + Invoke-Command @orbParameters } else { Invoke-Command -ScriptBlock { & $using:sb } } @@ -188,7 +197,7 @@ try { ($spnRelatedTypes -contains $errorType) ) { Write-Warning "First attempt failed with a possible SPN issue. Retrying with -IncludePortInSPN." - $sessionOptions = New-PssessionOption -IncludePortInSPN -NoCompression + $sessionOptions = New-PSSessionOption -IncludePortInSPN -NoCompression $remoteResults = Invoke-Command @parameters -SessionOption $sessionOptions Write-Host "$remoteResults" } else { diff --git a/tests/rcgmsa.Tests.ps1 b/tests/rcgmsa.Tests.ps1 new file mode 100644 index 0000000..0cb00fc --- /dev/null +++ b/tests/rcgmsa.Tests.ps1 @@ -0,0 +1,169 @@ +BeforeAll { + $script:credFile = [System.IO.Path]::GetTempFileName() + $script:keeperSecret = @{ + API_KEY = '06ed1705-a2d5-4d16-b3b2-1a2814e7ef67' + DB_PASS = 'SuperSecretPass' + Files = '{"license.key": "FileID_123"}' + Keys = 'Files' + } + $script:scriptPath = "$PSScriptRoot/../rcgmsa.ps1" + $script:secretName = '9vb_wew-d6_AmgUNmIO6Ez' + $script:setupPath = "$PSScriptRoot/../vault.ps1" + $script:vaultName = 'devops' + $script:vaultPassword = 'VaultPassword123' + + function script:Get-Credential { + [CmdletBinding()] + param( + [Parameter(Mandatory=$false)] + [string]$UserName, + + [Parameter(Mandatory=$false)] + [string]$Message + ) + $securePass = ConvertTo-SecureString $script:vaultPassword -AsPlainText -Force + return [PSCredential]::new($UserName, $securePass) + } + + . $script:setupPath -Path $script:credFile -Vault $script:vaultName + + Remove-Item Function:\script:Get-Credential -ErrorAction SilentlyContinue + + [Environment]::SetEnvironmentVariable( + "VAULT", + $script:vaultPassword, + [System.EnvironmentVariableTarget]::User + ) + + $securePass = ConvertTo-SecureString $script:vaultPassword -AsPlainText -Force + Unlock-SecretStore -Password $securePass + + Set-Secret -Name $secretName -Secret $script:keeperSecret -Vault $script:vaultName + + $nvc = [System.Collections.Specialized.NameValueCollection]::new() + $json = $script:keeperSecret.Files | ConvertFrom-Json + + foreach ($prop in $json.psobject.properties) { + $nvc.Add($prop.Name, $prop.Value) + } + $script:keeperSecret.Files = $nvc + + $script:keeperSecret["$($script:secretName).Files[license.key]"] = ` + [System.Text.Encoding]::UTF8.GetBytes('RealFileContent') +} + +Describe 'Integration Tests' { + + Context 'Input Validation' { + It 'Should accept valid hostnames or IPs' { + { & $script:scriptPath -Command 'Get-Date' -Computers 'localhost','127.0.0.1' -User 'svc_account$' } | + Should -Not -Throw + } + + It 'Should reject invalid characters in computer names' { + $expectedErr = "Cannot validate argument on parameter 'Computers'. Creativity meets catastrophe, invalid computer name: bad_host!" + { & $script:scriptPath -Command 'Get-Date' -Computers 'bad_host!' -User 'svc_account$' } | + Should -Throw $expectedErr + } + + It 'Should reject invalid characters in orb names' { + $expectedErr = "Cannot validate argument on parameter 'Orbs'. For FQDN's sake, invalid computer name: bad_orb!" + { & $script:scriptPath -Command 'Get-Date' -Computers 'localhost' -User 'svc_account$' -Orbs 'bad_orb!' } | + Should -Throw $expectedErr + } + + It 'Should output a semantic version number' { + $output = & $script:scriptPath -v 6>&1 + $output | Should -Match '^Version: \d+\.\d+\.\d+$' + } + } + + Context 'Keeper Vault Integration' { + BeforeAll { + Mock Get-Secret { + [CmdletBinding()] + param( + [Parameter(Position=0)]$Name, + [Parameter(Position=1)]$Vault, + [switch]$AsPlainText + ) + + if ($AsPlainText) { + return $script:keeperSecret + } + + return $script:keeperSecret[$Name] + } + } + + It 'Should retrieve secrets and process files when -Keeper is used' { + Mock Remove-Item -ParameterFilter { $Path -like '*\?*-?*-?*-?*-?*' } -MockWith {} + + & $script:scriptPath -Command 'hostname' -Computers 'localhost' ` + -User 'gmsa$' -Keeper $script:secretName -Vault 'devops' + + $sysTemp = [System.IO.Path]::GetTempPath() + $foundFile = Get-ChildItem -Path $sysTemp -Filter 'license.key' -Recurse -File | + Sort-Object CreationTime -Descending | + Select-Object -First 1 + + $foundFile | Should -Not -BeNullOrEmpty + $foundFile.Name | Should -Be 'license.key' + + $fileContent = [System.Text.Encoding]::UTF8.GetString( + [System.IO.File]::ReadAllBytes($foundFile.FullName) + ) + $fileContent | Should -Be 'RealFileContent' + + if ($foundFile) { + Remove-Item -Path $foundFile.DirectoryName -Recurse -Force + } + } + + It 'Should inject KEEPER_ variables into the scriptblock' { + & $script:scriptPath -Command 'whoami' -Computers 'localhost' ` + -User 'gmsa$' -Keeper $script:secretName 6>&1 | Out-String + + [Environment]::GetEnvironmentVariable( + 'KEEPER_API_KEY', [System.EnvironmentVariableTarget]::User + ) | Should -Be $script:keeperSecret.API_KEY + + [Environment]::GetEnvironmentVariable( + 'KEEPER_DB_PASS', [System.EnvironmentVariableTarget]::User + ) | Should -Be $script:keeperSecret.DB_PASS + } + } + + Context 'Logic Branching' { + It 'Should execute the orbs logic when provided' { + Mock Invoke-Command { return 'Jump Host Success' } + + & $script:scriptPath -Command 'whoami' -Computers 'target1' -User 'gmsa$' -Orbs 'jump1' + + Should -Invoke Invoke-Command -Times 1 -Exactly + } + + It 'Should retry with -IncludePortInSPN if a specific SPN error occurs' { + Mock Invoke-Command -ParameterFilter { + -not ($SessionOption.IncludePortInSPN) + } -MockWith { + $err = [System.Management.Automation.ErrorRecord]::new( + [Exception]::new('SPN Error'), + '-2144108387,PSSessionStateBroken', + [System.Management.Automation.ErrorCategory]::OpenError, + $null + ) + throw $err + } + + Mock Invoke-Command -MockWith { return 'Retry Successful' } + + & $script:scriptPath -Command 'hostname' -Computers 'localhost' -User 'gmsa$' + + Should -Invoke Invoke-Command -Times 2 -Exactly + Should -Invoke Invoke-Command -ParameterFilter { + $SessionOption.IncludePortInSPN -eq $true + } -Times 1 -Exactly + } + } +} diff --git a/vault.ps1 b/vault.ps1 index 3570563..17b2ea3 100644 --- a/vault.ps1 +++ b/vault.ps1 @@ -40,24 +40,24 @@ $requiredModules.ForEach{ } Write-Host "Getting secure store password" -$credential = Get-Credential -UserName $vault -$credential.Password | Export-Clixml -Path $path +$credential = Get-Credential -UserName $Vault +$credential.Password | Export-Clixml -Path $Path -$password = Import-CliXml -Path $path +$password = Import-CliXml -Path $Path $parameters = @{ - Name = $vault + Name = $Vault ModuleName = $requiredModules[0] VaultParameters = @{ Confirm = $false Interaction = $null Password = $password - PasswordTimeout = $timeout + PasswordTimeout = $Timeout } DefaultVault = $true } -Write-Host "Registering [$vault] vault" +Write-Host "Registering [$Vault] vault" [Environment]::SetEnvironmentVariable("VAULT", $password, [System.EnvironmentVariableTarget]::User) Register-SecretVault @parameters -Remove-Item -Path $path -Force +Remove-Item -Path $Path -Force Write-Host "All done!!!"