diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8fc1539..0d94b75 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -75,6 +75,9 @@ jobs: - msi-reinstall env: INTEGRATION: .\test\integration + VAULT_ADDR: http://127.0.0.1:8200 + VAULT_DEV_ROOT_TOKEN_ID: 323e3e66-e5fe-42ba-a4b9-fad293077754 + CcgVaultTestContainer: mcr.microsoft.com/windows/nanoserver:${{ endsWith(matrix.os, '2019') && '1809' || 'ltsc2022' }} defaults: run: shell: pwsh @@ -104,6 +107,38 @@ jobs: modules-to-cache: MSI:3.3.4 shell: pwsh + - name: Download Vault + run: | + $outpath = Join-Path -Path '.vault' -ChildPath 'bin' + $null = New-Item -Path $outpath -ItemType Directory -Force + + $w = Invoke-WebRequest -Uri https://www.vaultproject.io/downloads + $dl = $w.Links.href.Where({$_-match'\d+\.\d+\.\d+_windows_amd64.zip$'})[0] -as [uri] + $file = $dl.Segments[-1] + $outfile = Join-Path -Path $outpath -ChildPath $file + + Invoke-WebRequest -Uri $dl -OutFile $outfile + Expand-Archive -LiteralPath $outfile -DestinationPath $outpath + + $vault = Join-Path -Path $outpath -ChildPath 'vault.exe' -Resolve + + & $vault version + + echo "VAULT=$vault" >> $env:GITHUB_ENV + + - name: Setup Vault + run: | + .vault\setup.ps1 -VaultPath $env:VAULT + + - name: Setup Configs + run: | + New-Item -Path C:\ccg -ItemType Directory -Force + Copy-Item -LiteralPath .config/ccgvault.yml -Destination C:\ccg + $env:VAULT_DEV_ROOT_TOKEN_ID | Set-Content -LiteralPath C:\ccg\tokenfile -NoNewline -Encoding utf8NoBOM + + # we might need this later but for now this is done within the Pester tests + # Copy-Item -LiteralPath .config/credspec.json -Destination "$env:ProgramData\Docker\credentialspecs" -Force + - name: Set MSI names if: startsWith(matrix.mode, 'msi') run: | @@ -206,6 +241,7 @@ jobs: run: CcgVault.exe registry --permission --comclass - name: Test registry present + if: matrix.mode == 'bin' run: | . .\Initialize-Pester.ps1 Invoke-Pester -ExcludeTagFilter Absent -Tag Registry -Output Detailed @@ -247,3 +283,8 @@ jobs: run: | . .\Initialize-Pester.ps1 Invoke-Pester -ExcludeTagFilter Present -Tag NtService -Output Detailed + + - name: Test CcgVault end-to-end + run: | + . .\Initialize-Pester.ps1 + Invoke-Pester -Tag CcgVault -Output Detailed diff --git a/test/integration/.config/ccgvault.yml b/test/integration/.config/ccgvault.yml new file mode 100644 index 0000000..2697703 --- /dev/null +++ b/test/integration/.config/ccgvault.yml @@ -0,0 +1,18 @@ +--- +defaults: + vault_addr: http://127.0.0.1:8200 + auth: + type: token_from_file + config: + file_path: C:\ccg\tokenfile +sources: + static_kv1: + type: kv1 + config: + path: win/gmsa-getter + # mount_point: kv + static_kv2: + type: kv2 + config: + path: win/gmsa-getter + mount_point: secret diff --git a/test/integration/.config/credspec.json b/test/integration/.config/credspec.json new file mode 100644 index 0000000..218c23d --- /dev/null +++ b/test/integration/.config/credspec.json @@ -0,0 +1,30 @@ +{ + "CmsPlugins": [ + "ActiveDirectory" + ], + "DomainJoinConfig": { + "Sid": "S-1-5-21-702590844-1001920913-2680819671", + "MachineAccountName": "ccg-gmsa", + "Guid": "56d9b66c-d746-4f87-bd26-26760cfdca2e", + "DnsTreeName": "contoso.com", + "DnsName": "contoso.com", + "NetBiosName": "CONTOSO" + }, + "ActiveDirectoryConfig": { + "GroupManagedServiceAccounts": [ + { + "Name": "ccg-gmsa", + "Scope": "contoso.com" + }, + { + "Name": "ccg-gmsa", + "Scope": "CONTOSO" + } + ], + "HostAccountConfig": { + "PortableCcgVersion": "1", + "PluginGUID": "{01BF101D-BFB6-433F-B416-02885CDC5AD3}", + "PluginInput": "C:\\ccg\\ccgvault.yml|static_kv2" + } + } +} diff --git a/test/integration/.vault/.gitignore b/test/integration/.vault/.gitignore new file mode 100644 index 0000000..e660fd9 --- /dev/null +++ b/test/integration/.vault/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/test/integration/.vault/setup.ps1 b/test/integration/.vault/setup.ps1 new file mode 100644 index 0000000..b6e43ca --- /dev/null +++ b/test/integration/.vault/setup.ps1 @@ -0,0 +1,62 @@ +#Requires -Version 7.0 +[CmdletBinding()] +param( + [Parameter()] + [ValidateNotNullOrEmpty()] + [String] + $VaultPath = 'vault.exe', + + [Parameter()] + [hashtable] + $Kv1Data = @{ + Username = 'ccg-reader-1' + Domain = 'contoso.com' + Password = 'password1' + } , + + [Parameter()] + [hashtable] + $Kv2Data = @{ + Username = 'ccg-reader-2' + Domain = 'contoso.com' + Password = 'password2' + } +) + +if (-not $env:VAULT_ADDR) { + $env:VAULT_ADDR = 'http://127.0.0.1:8200' +} + +if (-not $env:VAULT_DEV_ROOT_TOKEN_ID) { + $env:VAULT_DEV_ROOT_TOKEN_ID = '323e3e66-e5fe-42ba-a4b9-fad293077754' +} + +if (-not $env:VAULT_TOKEN) { + $env:VAULT_TOKEN = $env:VAULT_DEV_ROOT_TOKEN_ID +} + +$env:VAULT_CLIENT_TIMEOUT = 1 +& $VaultPath status +$running = $? +$env:VAULT_CLIENT_TIMEOUT = '' + +if ($running) { + return +} + +$w32p = Get-CimClass -ClassName Win32_Process +$w32startup = Get-CimClass -ClassName Win32_ProcessStartup +$vars = [System.Environment]::GetEnvironmentVariables([System.EnvironmentVariableTarget]::Process).GetEnumerator().ForEach({ + "{0}={1}" -f $_.Name, $_.Value +}) -as [string[]] +$vars | Write-Verbose +$startup = New-CimInstance -CiMClass $w32startup -Property @{ EnvironmentVariables = $vars } -ClientOnly +$proc = Invoke-CimMethod -CiMClass $w32p -Name Create -Arguments @{ + CommandLine = "$VaultPath server -dev" + ProcessStartupInformation = $startup +} +$proc.ProcessId | Set-Content -LiteralPath $env:TMP\vaultpid -NoNewline -Encoding utf8NoBOM + +& $VaultPath secrets enable -path kv -version 1 kv +& $VaultPath kv put kv/win/gmsa-getter $($Kv1Data.GetEnumerator().ForEach({"{0}={1}" -f $_.Name, $_.Value})) +& $VaultPath kv put secret/win/gmsa-getter $($Kv2Data.GetEnumerator().ForEach({"{0}={1}" -f $_.Name, $_.Value})) diff --git a/test/integration/COMPlus.Tests.ps1 b/test/integration/COMPlus.Tests.ps1 index d3deaa1..e06752b 100644 --- a/test/integration/COMPlus.Tests.ps1 +++ b/test/integration/COMPlus.Tests.ps1 @@ -70,7 +70,7 @@ Describe 'COM+ tests' -Tag ComPlus { $app.Name | Should -BeExactly CcgVault } - It 'App identity is ' { + It 'App identity is ' { $appInfo.Identity | Should -Be $Identity } diff --git a/test/integration/CcgVault.Tests.ps1 b/test/integration/CcgVault.Tests.ps1 new file mode 100644 index 0000000..5342903 --- /dev/null +++ b/test/integration/CcgVault.Tests.ps1 @@ -0,0 +1,176 @@ +#Requires -Module @{ ModuleName = 'Pester'; ModuleVersion = '5.2.0' } +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'CredSpecPath')] +param( + [Parameter()] + [ValidateNotNullOrEmpty()] + [String] + $CcgVault = "$env:CcgVaultBin\CcgVault.exe" , + + [Parameter()] + [ValidateNotNullOrEmpty()] + [String] + $Config = 'C:\ccg\ccgvault.yml' , + + [Parameter()] + [ValidateNotNullOrEmpty()] + [String] + $ContainerImage = $env:CcgVaultTestContainer, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [String] + $CredSpecPath = "$env:ProgramData\Docker\credentialspecs" +) + +BeforeAll { + $data = Import-PowerShellDataFile -LiteralPath $PSScriptRoot\TestData.psd1 + + $Script:ccgPluginId = [Guid]::Parse($data.ComPlus.CcgPlugin.ID) + + $kv1data = $data.CcgVault.Kv1Data + $kv2data = $data.CcgVault.Kv2Data + + $Script:expected_data = @{ + static_kv1 = $kv1data + static_kv2 = $kv2data + } + + # this doesn't work for some reason, haven't figured out why yet. + # if the path is wrong, the tests fail, so it seems like it tries to execute it at least, + # but it does not appear to actually start the Vault server; the tests only work if I run + # it separately first (and even in that case, tests fail when the path is wrong). + # & "$PSScriptRoot\.vault\setup.ps1" -Kv1Data $kv1data -Kv2Data $kv2data -ErrorAction Stop -Verbose +} + +Describe 'CcgVault tests' -Tag CcgVault { + Context 'CLI test command w/ source: ' -Tag CLI -Foreach @( + @{ source = 'static_kv1' } + @{ source = 'static_kv2' } + ) { + BeforeAll { + $Script:expected = $expected_data[$source] + $Script:output = & $CcgVault test --input "${Config}|${source}" + $Script:result = $output | + ForEach-Object -Begin { $Script:r = @{} } -Process { + $key, $value = $_ -split ': ' + $r[$key] = $value + } -End { $r } + } + + It "Source '' has Username ''" { + $result.User | Should -BeExactly $expected.Username + } + + It "Source '' has Domain ''" { + $result.Domain | Should -BeExactly $expected.Domain + } + + It "Source '' has Password ''" { + $result.Pass | Should -BeExactly $expected.Password + } + } + + Context 'Docker run test w/ source: ' -Tag Docker -Foreach @( + @{ source = 'static_kv1' } + @{ source = 'static_kv2' } + ) { + BeforeAll { + $Script:timeout = 60 + $Script:time = Get-Date + $Script:logname = $data.CcgVault.LogName + + $credspecfile = "credspec_${source}.json" + $credspec = Join-Path -Path $CredSpecPath -ChildPath $credspecfile + + $spec = Get-Content -LiteralPath "$PSScriptRoot/.config/credspec.json" -Raw | ConvertFrom-Json -AsHashtable -Depth 10 + $spec.ActiveDirectoryConfig.HostAccountConfig.PluginInput = "${Config}|${source}" + + ConvertTo-Json -InputObject $spec -Depth 10 | Set-Content -LiteralPath $credspec -Encoding utf8NoBOM -Force + + $containerName = "ccgtest_${source}" + + & docker run --user "NT AUthority\System" --security-opt "credentialspec=file://${credspecfile}" --name "$containerName" --rm -d "$ContainerImage" cmd /c ping -t localhost + + if (-not $?) { + throw "Error runnung docker command." + } + + function Wait-WinEvent { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [String] + $LogName , + + [Parameter()] + [UInt16] + $EventID , + + [Parameter()] + [ScriptBlock] + $Condition , + + [Parameter()] + [DateTime] + $MinimumDateTime , + + [Parameter()] + [UInt16] + $TimeoutSeconds , + + [Parameter()] + [UInt16] + $SleepMilliseconds = 250 + ) + + End { + $delay = $false + $timeoutStart = [DateTime]::Now + do { + if ($delay) { + Start-Sleep -Milliseconds $SleepMilliseconds + } + $ev = Get-WinEvent -LogName $LogName -FilterXPath "*[System[EventID=${EventID}]]" -MaxEvents 1 -ErrorAction SilentlyContinue + $delay = $delay -or $SleepMilliseconds -as [bool] + } until ( + ($null -eq $MinimumDateTime -or $ev.TimeCreated -gt $MinimumDateTime) -and + ($null -eq $Condition -or (ForEach-Object -InputObject $ev -Process $Condition)) -or + (-not $TimeoutSeconds -or ($timedOut = ([DateTime]::Now - $timeoutStart).TotalSeconds -gt $TimeoutSeconds)) + ) + + if (-not $timedOut) { + $ev + } + } + } + } + + It "The plugin was instantiated" { + $event1 = Wait-WinEvent -LogName $logname -EventId 1 -Condition { + [guid]::Parse($_.Properties[0].Value) -eq $ccgPluginId + } -MinimumDateTime $time -TimeoutSeconds $timeout + + $event1 | Should -Not -BeNullOrEmpty + } + + # EventID 8 gets logged on my local machine when used with test creds + # and the contoso.com domain, but it doesn't happen in CI nor in a test + # server; maybe this is because my machine is a desktop OS, maybe it's + # because it's domain-joined, not sure. Will keep it around as a possible + # future test-case. + # + # It "The credential fails without a real domain" -Tag NoDomain { + # $event8 = Wait-WinEvent -LogName $logname -EventId 8 -Condition { + # [guid]::Parse($_.Properties[1].Value) -eq $ccgPluginId + # } -MinimumDateTime $time -TimeoutSeconds $timeout + + # $event8 | Should -Not -BeNullOrEmpty + # } + + AfterAll { + & docker stop "$containerName" + Remove-Item -LiteralPath $credspec -Force + } + } +} diff --git a/test/integration/TestData.psd1 b/test/integration/TestData.psd1 index 9f6beb3..aab7dc4 100644 --- a/test/integration/TestData.psd1 +++ b/test/integration/TestData.psd1 @@ -16,4 +16,18 @@ Registry = @{ Path = 'HKLM:\SYSTEM\CurrentControlSet\Control\CCG\COMClasses' } + + CcgVault = @{ + LogName = 'Microsoft-Windows-Containers-CCG/Admin' + Kv1Data = @{ + Username = 'ccg-reader-1' + Domain = 'contoso.com' + Password = 'password1' + } + Kv2Data = @{ + Username = 'ccg-reader-2' + Domain = 'contoso.com' + Password = 'password2' + } + } }