diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b67dd3762..a381ea780 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -354,8 +354,6 @@ jobs: $VSINSTALLDIR = $(vswhere.exe -latest -requires Microsoft.VisualStudio.Component.VC.Llvm.Clang -property installationPath) Write-Output "LIBCLANG_PATH=$VSINSTALLDIR\VC\Tools\Llvm\x64\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - # Install Visual Studio Developer PowerShell Module for cmdlets such as Enter-VsDevShell - Install-Module VsDevShell -Force shell: pwsh - name: Configure Windows (arm) runner @@ -696,9 +694,6 @@ jobs: # NASM is required by aws-lc-rs (used as rustls crypto backend) choco install nasm - # Install Visual Studio Developer PowerShell Module for cmdlets such as Enter-VsDevShell - Install-Module VsDevShell -Force - # We need to add the NASM binary folder to the PATH manually. Write-Output "$Env:ProgramFiles\NASM" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append shell: pwsh @@ -707,9 +702,31 @@ jobs: id: find_mc if: ${{ matrix.os == 'windows' }} run: | - Enter-VsDevShell - $path = (Get-Command -Type Application mc).Source | Split-Path -Parent + $sdkRoots = @( + $Env:WindowsSdkDir + (Get-ItemPropertyValue -Path "HKLM:\SOFTWARE\Microsoft\Windows Kits\Installed Roots" -Name KitsRoot10 -ErrorAction SilentlyContinue) + "${Env:ProgramFiles(x86)}\Windows Kits\10" + ) | Where-Object { $_ } | Select-Object -Unique + $candidates = @() + if ($Env:WindowsSdkVerBinPath) { + $candidates += Join-Path $Env:WindowsSdkVerBinPath "mc.exe" + $candidates += Join-Path $Env:WindowsSdkVerBinPath "x64\mc.exe" + } + foreach ($root in $sdkRoots) { + $bin = Join-Path $root "bin" + $candidates += Join-Path $bin "x64\mc.exe" + $candidates += Get-ChildItem -LiteralPath $bin -Directory -ErrorAction SilentlyContinue | + Where-Object Name -Match '^\d+\.\d+\.\d+\.\d+$' | + Sort-Object { [version]$_.Name } -Descending | + ForEach-Object { Join-Path $_.FullName "x64\mc.exe" } + } + $mc = $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 + if (-Not $mc) { + throw "mc.exe was not found in the installed Windows SDK" + } + $path = Split-Path -Parent $mc Write-Output "windows_sdk_ver_bin_path=$path" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + Write-Output $path | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 shell: pwsh - name: Build @@ -870,6 +887,9 @@ jobs: $DAgentSessionExecutable = Join-Path $TargetOutputPath "DevolutionsSession.exe" echo "dagent-session-executable=$DAgentSessionExecutable" >> $Env:GITHUB_OUTPUT + $DAgentPolicyConsentHelper = Join-Path $TargetOutputPath "DevolutionsAgentPolicyConsent.exe" + echo "dagent-policy-consent-helper=$DAgentPolicyConsentHelper" >> $Env:GITHUB_OUTPUT + $DAgentUpdaterExecutable = Join-Path $TargetOutputPath "DevolutionsAgentUpdater.exe" echo "dagent-updater-executable=$DAgentUpdaterExecutable" >> $Env:GITHUB_OUTPUT } @@ -975,6 +995,37 @@ jobs: if: ${{ matrix.os == 'windows' }} uses: microsoft/setup-msbuild@v3 + - name: Find mc.exe + id: find_mc + if: ${{ matrix.os == 'windows' }} + run: | + $sdkRoots = @( + $Env:WindowsSdkDir + (Get-ItemPropertyValue -Path "HKLM:\SOFTWARE\Microsoft\Windows Kits\Installed Roots" -Name KitsRoot10 -ErrorAction SilentlyContinue) + "${Env:ProgramFiles(x86)}\Windows Kits\10" + ) | Where-Object { $_ } | Select-Object -Unique + $candidates = @() + if ($Env:WindowsSdkVerBinPath) { + $candidates += Join-Path $Env:WindowsSdkVerBinPath "mc.exe" + $candidates += Join-Path $Env:WindowsSdkVerBinPath "x64\mc.exe" + } + foreach ($root in $sdkRoots) { + $bin = Join-Path $root "bin" + $candidates += Join-Path $bin "x64\mc.exe" + $candidates += Get-ChildItem -LiteralPath $bin -Directory -ErrorAction SilentlyContinue | + Where-Object Name -Match '^\d+\.\d+\.\d+\.\d+$' | + Sort-Object { [version]$_.Name } -Descending | + ForEach-Object { Join-Path $_.FullName "x64\mc.exe" } + } + $mc = $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 + if (-Not $mc) { + throw "mc.exe was not found in the installed Windows SDK" + } + $path = Split-Path -Parent $mc + Write-Output "windows_sdk_ver_bin_path=$path" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + Write-Output $path | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 + shell: pwsh + - name: Build run: | if ($Env:RUNNER_OS -eq "Windows") { @@ -985,6 +1036,7 @@ jobs: $Env:DAGENT_TUN2SOCKS_EXE = "${{ steps.tun2socks.outputs.tun2socks-executable-path }}" $Env:DAGENT_WINTUN_DLL = "${{ steps.tun2socks.outputs.wintun-library-path }}" $Env:DAGENT_MULTI_PWSH_EXECUTABLE = "${{ steps.multi-pwsh.outputs.executable-path }}" + $Env:WindowsSdkVerBinPath = '${{ steps.find_mc.outputs.windows_sdk_ver_bin_path }}' } if ($Env:RUNNER_OS -eq "Linux") { @@ -1003,6 +1055,28 @@ jobs: DAGENT_EXECUTABLE: ${{ steps.load-variables.outputs.dagent-executable }} TARGET_OUTPUT_PATH: ${{ steps.load-variables.outputs.target-output-path }} + - name: Build NativeAOT policy consent helper + if: ${{ matrix.os == 'windows' }} + run: | + $Rid = "win-${{ matrix.arch }}" + $Output = Split-Path -Parent '${{ steps.load-variables.outputs.dagent-policy-consent-helper }}' + dotnet publish package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj ` + --configuration Release ` + --runtime $Rid ` + --output $Output ` + -p:Version=${{ needs.preflight.outputs.version }} + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + $Helper = '${{ steps.load-variables.outputs.dagent-policy-consent-helper }}' + if (-Not (Test-Path -LiteralPath $Helper -PathType Leaf)) { + throw "NativeAOT policy consent helper was not produced" + } + if ((Get-Item -LiteralPath $Helper).Length -gt 8MB) { + throw "NativeAOT policy consent helper exceeds 8 MiB" + } + shell: pwsh + - name: Package if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} run: | @@ -1016,6 +1090,7 @@ jobs: $Env:DAGENT_PEDM_SHELL_EXT_DLL = "${{ steps.load-variables.outputs.dagent-pedm-shell-ext-dll }}" $Env:DAGENT_PEDM_SHELL_EXT_MSIX = "${{ steps.load-variables.outputs.dagent-pedm-shell-ext-msix }}" $Env:DAGENT_SESSION_EXECUTABLE = "${{ steps.load-variables.outputs.dagent-session-executable }}" + $Env:DAGENT_POLICY_CONSENT_HELPER = "${{ steps.load-variables.outputs.dagent-policy-consent-helper }}" $Env:DAGENT_TUN2SOCKS_EXE = "${{ steps.tun2socks.outputs.tun2socks-executable-path }}" $Env:DAGENT_WINTUN_DLL = "${{ steps.tun2socks.outputs.wintun-library-path }}" $Env:DAGENT_MULTI_PWSH_EXECUTABLE = "${{ steps.multi-pwsh.outputs.executable-path }}" @@ -1122,6 +1197,25 @@ jobs: run: dotnet test utils/dotnet/GatewayUtils.sln shell: pwsh + agent-installer-event-log-tests: + name: Agent installer Event Log lifecycle tests + runs-on: windows-2022 + needs: [preflight] + + steps: + - name: Checkout ${{ github.repository }} + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + + - name: Tests + run: dotnet test package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj + shell: pwsh + + - name: Policy consent helper tests + run: dotnet test package/AgentPolicyConsent.Tests/DevolutionsAgentPolicyConsent.Tests.csproj -c Release + shell: pwsh + winapi-sanitizer-tests: name: Windows API sanitizer tests @@ -1283,12 +1377,14 @@ jobs: name: Agent policy end-to-end test runs-on: windows-2022 needs: [preflight] + env: + AGENT_POLICY_TEST_SHA: ${{ inputs.ref || github.event.pull_request.head.sha || needs.preflight.outputs.ref }} steps: - name: Checkout ${{ github.repository }} uses: actions/checkout@v6 with: - ref: ${{ needs.preflight.outputs.ref }} + ref: ${{ env.AGENT_POLICY_TEST_SHA }} - name: Setup Rust cache uses: ./.github/actions/setup-rust-cache @@ -1311,8 +1407,13 @@ jobs: Add-Content -Path $env:GITHUB_PATH -Value $toolsDir - name: Build Agent policy test executables + id: build-policy-executables shell: pwsh run: | + $actualCommit = git rev-parse HEAD + if ($LASTEXITCODE -ne 0 -or $actualCommit -ne $env:AGENT_POLICY_TEST_SHA) { + throw "Agent policy tests must build the requested commit $env:AGENT_POLICY_TEST_SHA, got $actualCommit" + } cargo build --locked -p devolutions-agent --features dev-skip-broker-signature if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE @@ -1322,7 +1423,18 @@ jobs: exit $LASTEXITCODE } + - name: Run Agent policy tester as standard user + shell: pwsh + run: | + ./crates/agent-policy-tester/run-unelevated.ps1 + $exitCode = $LASTEXITCODE + Get-Content -Path ./crates/agent-policy-tester/agent-policy-tester-unelevated.out + if ($exitCode -ne 0) { + exit $exitCode + } + - name: Run Agent policy tester as LocalSystem + if: ${{ !cancelled() && steps.build-policy-executables.outcome == 'success' }} shell: pwsh run: | $scriptPath = Resolve-Path -Path "./crates/agent-policy-tester/run-as-system.ps1" @@ -1333,6 +1445,10 @@ jobs: exit $exitCode } + - name: Run policy route authorization tests + shell: pwsh + run: cargo test --locked -p now-package-broker --features dev-skip-broker-signature + - name: Show sccache stats if: ${{ needs.preflight.outputs.sccache == 'true' && !cancelled() }} shell: pwsh @@ -1366,7 +1482,7 @@ jobs: success: name: Success if: ${{ always() }} - needs: [tests, agent-tunnel-e2e, agent-policy-e2e, lints, check-dependencies, jetsocat-lipo, devolutions-gateway-powershell, devolutions-gateway, devolutions-gateway-merge, devolutions-pedm-desktop, devolutions-agent, devolutions-agent-merge, devolutions-pedm-client, dotnet-utils-tests, winapi-sanitizer-tests, winapi-miri, pedm-simulator, secure-memory-verifier] + needs: [tests, agent-tunnel-e2e, agent-policy-e2e, lints, check-dependencies, jetsocat-lipo, devolutions-gateway-powershell, devolutions-gateway, devolutions-gateway-merge, devolutions-pedm-desktop, devolutions-agent, devolutions-agent-merge, devolutions-pedm-client, dotnet-utils-tests, agent-installer-event-log-tests, winapi-sanitizer-tests, winapi-miri, pedm-simulator, secure-memory-verifier] runs-on: ubuntu-latest steps: diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 27ee014f9..af56e0b9f 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -322,7 +322,7 @@ jobs: run: | $IncludePattern = @(switch ('${{ matrix.project }}') { 'devolutions-gateway' { @('DevolutionsGateway.exe') } - 'devolutions-agent' { @('DevolutionsAgent.exe', 'DevolutionsAgentUpdater.exe', 'DevolutionsPedmShellExt.dll', 'DevolutionsPedmShellExt.msix', 'DevolutionsDesktopAgent.exe') } + 'devolutions-agent' { @('DevolutionsAgent.exe', 'DevolutionsAgentUpdater.exe', 'DevolutionsAgentPolicyConsent.exe', 'DevolutionsPedmShellExt.dll', 'DevolutionsPedmShellExt.msix', 'DevolutionsDesktopAgent.exe') } 'jetsocat' { @('jetsocat.exe', 'jetsocat') } }) $ExcludePattern = "*.pdb" @@ -495,6 +495,7 @@ jobs: $Env:DAGENT_PEDM_SHELL_EXT_DLL = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.dll' -File | Select-Object -First 1 $Env:DAGENT_PEDM_SHELL_EXT_MSIX = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.msix' -File | Select-Object -First 1 $Env:DAGENT_SESSION_EXECUTABLE = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsSession.exe' -File | Select-Object -First 1 + $Env:DAGENT_POLICY_CONSENT_HELPER = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsAgentPolicyConsent.exe' -File | Select-Object -First 1 $Env:DAGENT_TUN2SOCKS_EXE = Join-Path $ArchRoot 'tun2socks.exe' $Env:DAGENT_WINTUN_DLL = Join-Path $ArchRoot 'wintun.dll' $MultiPwshDirectory = Join-Path $Env:RUNNER_TEMP 'multi-pwsh' 'windows' $Arch @@ -508,6 +509,7 @@ jobs: Write-Host "DAGENT_PEDM_SHELL_EXT_DLL = ${Env:DAGENT_PEDM_SHELL_EXT_DLL}" Write-Host "DAGENT_PEDM_SHELL_EXT_MSIX = ${Env:DAGENT_PEDM_SHELL_EXT_MSIX}" Write-Host "DAGENT_SESSION_EXECUTABLE = ${Env:DAGENT_SESSION_EXECUTABLE}" + Write-Host "DAGENT_POLICY_CONSENT_HELPER = ${Env:DAGENT_POLICY_CONSENT_HELPER}" Write-Host "DAGENT_TUN2SOCKS_EXE = ${Env:DAGENT_TUN2SOCKS_EXE}" Write-Host "DAGENT_WINTUN_DLL = ${Env:DAGENT_WINTUN_DLL}" Write-Host "DAGENT_MULTI_PWSH_EXECUTABLE = ${Env:DAGENT_MULTI_PWSH_EXECUTABLE}" @@ -534,7 +536,8 @@ jobs: @((Join-Path $ArchRoot DesktopAgent), (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.dll' | Select-Object -First 1), (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.msix' | Select-Object -First 1), - (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsSession.exe' | Select-Object -First 1)) | ForEach-Object { + (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsSession.exe' | Select-Object -First 1), + (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsAgentPolicyConsent.exe' | Select-Object -First 1)) | ForEach-Object { Remove-Item $_ -Recurse -ErrorAction SilentlyContinue | Out-Null } } diff --git a/Cargo.lock b/Cargo.lock index 8e668cbb0..74e389f1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,15 @@ dependencies = [ "serde_json", "tempfile", "tokio 1.52.3", + "win-api-wrappers", + "windows 0.61.3", +] + +[[package]] +name = "agent-sysevent-codes" +version = "0.0.0" +dependencies = [ + "sysevent", ] [[package]] @@ -1782,6 +1791,7 @@ dependencies = [ "uuid", "win-api-wrappers", "windows 0.61.3", + "windows-registry 0.5.3", "x509-parser", ] @@ -2672,8 +2682,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1", - "windows-result 0.4.1", + "windows-link 0.1.3", + "windows-result 0.3.4", ] [[package]] @@ -3208,7 +3218,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -4804,6 +4814,7 @@ dependencies = [ name = "now-package-broker" version = "0.0.0" dependencies = [ + "agent-sysevent-codes", "anyhow", "async-trait", "axum 0.8.9", @@ -4826,11 +4837,14 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "sysevent", + "sysevent-winevent", "tempfile", "tokio 1.52.3", "tokio-util", "tower-service", "tracing", + "unicode-normalization", "uuid", "widestring 1.2.1", "win-api-wrappers", @@ -4854,9 +4868,9 @@ dependencies = [ [[package]] name = "now-policy-api" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b61d66fd334d2dac6150d1ab83f3831ec4b0ee20272fb3386fbe5b5e31c6663" +checksum = "fcd733577077eb870204207836f596ec3fc8fe4876d3652be7f0dee4a52e0dc8" dependencies = [ "chrono", "derive_more", @@ -4871,9 +4885,9 @@ dependencies = [ [[package]] name = "now-policy-server-template" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fee165964d3b2dddfa2c6283b820d5cad337277d51365cf77e6b1376668f529d" +checksum = "567491bfc7bf5615d1854cc951172987fe638084b86c6c153ad6d860f0096ae8" dependencies = [ "aide 0.15.1", "async-trait", diff --git a/ci/README.md b/ci/README.md index 264a9500d..bac41e802 100644 --- a/ci/README.md +++ b/ci/README.md @@ -14,14 +14,32 @@ This folder contains PowerShell scripts for CI, building, and packaging. | Gateway | Windows (regular) | `build.ps1 gateway`
`copy-ps-module.ps1`
`package-gateway-windows.ps1` | | Gateway | Windows (assembled) | `build.ps1 gateway`
`copy-ps-module.ps1`
`package-gateway-windows.ps1 -Generate`
`package-assembled.ps1 gateway` | | Gateway | Linux | `build.ps1 gateway`
`package-gateway-linux.ps1` (not available yet) | -| Agent | Windows (regular) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`package-agent-windows.ps1` | -| Agent | Windows (assembled) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`package-agent-windows.ps1 -Generate`
`package-assembled.ps1 agent` | +| Agent | Windows (regular) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`dotnet publish ..\package\AgentPolicyConsent\DevolutionsAgentPolicyConsent.csproj -c Release -r win-x64 --self-contained`
`package-agent-windows.ps1` with the arguments below | +| Agent | Windows (assembled) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`dotnet publish ..\package\AgentPolicyConsent\DevolutionsAgentPolicyConsent.csproj -c Release -r win-x64 --self-contained`
`package-agent-windows.ps1 -Generate` with the arguments below
`package-assembled.ps1 agent` | | Jetsocat | Windows/macOS/Linux | `build.ps1 jetsocat`
Jetsocat is not packaged. | | Session | Windows/macOS/Linux | `build.ps1 session`
Session is not packaged. | | PEDM module | Windows | `build.ps1 pedm` | | PowerShell module | Windows | `copy-ps-module.ps1` | | Desktop Agent | Windows | `..\dotnet\DesktopAgent\build.ps1` | +## Agent Windows package arguments + +Pass every staged artifact to `package-agent-windows.ps1`. + +```powershell +.\package-agent-windows.ps1 ` + -Exe ` + -UpdaterExe ` + -PedmDll ` + -PedmMsix ` + -SessionExe ` + -PolicyConsentHelper ..\package\AgentPolicyConsent\bin\Release\net10.0-windows\win-x64\publish\DevolutionsAgentPolicyConsent.exe ` + -Architecture x64 ` + -Outfile +``` + +For an assembled package, replace `-Outfile ` with `-Generate`. + ## What is the difference between _Windows (regular)_ and _Windows (assembled)_? _Windows (regular)_ is the "normal" build process where the MSI is built by WiX but not signed. This is used in _ci.yaml_. _Windows (assembled)_ is a two-step process where the `-Generate` flag is used to build supporting files for the MSI, including DLLs, language transforms, and _cmd_ scripts. The MSI is assembled in second step using _package-assembled.ps1_. The two-step approach is described [here](https://github.com/oleg-shilo/wixsharp/wiki/Developer's-Guide#compiling-wix-project). diff --git a/ci/package-agent-windows.ps1 b/ci/package-agent-windows.ps1 index 4a37cc512..bb97553a5 100644 --- a/ci/package-agent-windows.ps1 +++ b/ci/package-agent-windows.ps1 @@ -11,6 +11,8 @@ param( [parameter(Mandatory = $true)] [string] $SessionExe, [parameter(Mandatory = $true)] + [string] $PolicyConsentHelper, + [parameter(Mandatory = $true)] [ValidateSet('x64', 'arm64')] [string] $Architecture, [string] $Outfile @@ -43,8 +45,9 @@ function Set-FileNameAndCopy { # If the name is already correct, return the original path without copying if ($currName -ieq $NewName) { - Write-Host "Using $Path without copying" - return $Path + $resolvedPath = (Resolve-Path -LiteralPath $Path).Path + Write-Host "Using $resolvedPath without copying" + return $resolvedPath } # Copy to a temporary directory. @@ -98,6 +101,9 @@ function New-AgentMsi() { # The path to the devolutions-session.exe file. [string] $SessionExe, [parameter(Mandatory = $true)] + # The path to the signed DevolutionsAgentPolicyConsent.exe file. + [string] $PolicyConsentHelper, + [parameter(Mandatory = $true)] [ValidateSet('x64', 'arm64')] # Architecture: x64 or arm64 [string] $Architecture, @@ -120,6 +126,7 @@ function New-AgentMsi() { $PedmDll = Convert-Path -Path $PedmDll $PedmMsix = Convert-Path -Path $PedmMsix $SessionExe = Convert-Path -Path $SessionExe + $PolicyConsentHelper = Convert-Path -Path $PolicyConsentHelper if ($Outfile) { $Outfile = Convert-Path -Path $Outfile } @@ -137,6 +144,7 @@ function New-AgentMsi() { $myUpdaterExe = Set-FileNameAndCopy -Path $UpdaterExe -NewName 'DevolutionsAgentUpdater.exe' # The session is a service that gets launched on demand. $mySessionExe = Set-FileNameAndCopy -Path $SessionExe -NewName 'DevolutionsSession.exe' + $myPolicyConsentHelper = Set-FileNameAndCopy -Path $PolicyConsentHelper -NewName 'DevolutionsAgentPolicyConsent.exe' Write-Output "$repoDir\dotnet\DesktopAgent\bin\Release\net48\DevolutionsDesktopAgent.exe" @@ -145,6 +153,7 @@ function New-AgentMsi() { Set-EnvVarPath 'DAGENT_PEDM_SHELL_EXT_DLL' $myPedmDll Set-EnvVarPath 'DAGENT_PEDM_SHELL_EXT_MSIX' $myPedmMsix Set-EnvVarPath 'DAGENT_SESSION_EXECUTABLE' $mySessionExe + Set-EnvVarPath 'DAGENT_POLICY_CONSENT_HELPER' $myPolicyConsentHelper # The actual DevolutionsDesktopAgent.exe will be `\dotnet\DesktopAgent\bin\Release\net48\DevolutionsDesktopAgent.exe`. # After install, the contents of `net48` will be copied to `C:\Program Files\Devolutions\Agent\desktop\`. @@ -184,4 +193,4 @@ function New-AgentMsi() { Pop-Location } -New-AgentMsi -Generate:($Generate.IsPresent) -Exe $Exe -UpdaterExe $UpdaterExe -PedmDll $PedmDll -PedmMsix $PedmMsix -SessionExe $SessionExe -Architecture $Architecture -Outfile $Outfile +New-AgentMsi -Generate:($Generate.IsPresent) -Exe $Exe -UpdaterExe $UpdaterExe -PedmDll $PedmDll -PedmMsix $PedmMsix -SessionExe $SessionExe -PolicyConsentHelper $PolicyConsentHelper -Architecture $Architecture -Outfile $Outfile diff --git a/crates/agent-policy-tester/Cargo.toml b/crates/agent-policy-tester/Cargo.toml index ba2f20f77..69f0f8d67 100644 --- a/crates/agent-policy-tester/Cargo.toml +++ b/crates/agent-policy-tester/Cargo.toml @@ -12,6 +12,8 @@ fastrand = "2" serde_json = "1" tempfile = "3" tokio = { version = "1", features = ["io-util", "macros", "net", "process", "rt-multi-thread", "time"] } +win-api-wrappers = { path = "../win-api-wrappers" } +windows = { version = "0.61", features = ["Win32_Security", "Win32_System_Threading"] } [lints] workspace = true diff --git a/crates/agent-policy-tester/run-as-system.ps1 b/crates/agent-policy-tester/run-as-system.ps1 index 8520d10ec..f26337f00 100644 --- a/crates/agent-policy-tester/run-as-system.ps1 +++ b/crates/agent-policy-tester/run-as-system.ps1 @@ -4,11 +4,18 @@ $workspacePath = (Resolve-Path (Join-Path $PSScriptRoot "../..")).Path $testerPath = Join-Path $workspacePath "target/debug/agent-policy-tester.exe" $agentPath = Join-Path $workspacePath "target/debug/devolutions-agent.exe" $outputPath = Join-Path $PSScriptRoot "agent-policy-tester.out" -$stagingPath = Join-Path $env:ProgramData "dgw-agent-policy-tester-$([guid]::NewGuid().ToString('N'))" +$stagingPath = Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) "dgw-agent-policy-tester-$([guid]::NewGuid().ToString('N'))" $stagedTesterPath = Join-Path $stagingPath "agent-policy-tester.exe" +$exitCode = 1 try { Set-Content -LiteralPath $outputPath -Value "" + if (-not [System.Security.Principal.WindowsIdentity]::GetCurrent().IsSystem) { + throw "This runner requires LocalSystem" + } + if ([System.IO.DriveInfo]::new([System.IO.Path]::GetPathRoot($workspacePath)).DriveType -ne 'Fixed') { + throw "Use a local fixed-volume workspace path visible to LocalSystem, not a mapped drive" + } Add-Type -TypeDefinition @' using System; using System.ComponentModel; @@ -61,7 +68,6 @@ public static class AgentPolicyTesterNativeDirectory if (Get-ChildItem -LiteralPath $stagingPath -Force) { throw "The atomically protected staged tester directory was not empty" } - Copy-Item -LiteralPath $testerPath -Destination $stagedTesterPath & icacls.exe $stagedTesterPath /setowner '*S-1-5-18' 2>&1 | Out-File $outputPath -Append if ($LASTEXITCODE -ne 0) { @@ -76,7 +82,7 @@ public static class AgentPolicyTesterNativeDirectory "Staged policy tester at $stagedTesterPath" | Out-File $outputPath -Append Get-Acl -LiteralPath $stagingPath | Format-List Owner, Sddl | Out-File $outputPath -Append Get-Acl -LiteralPath $stagedTesterPath | Format-List Owner, Sddl | Out-File $outputPath -Append - & $stagedTesterPath $agentPath 2>&1 | Out-File $outputPath -Append + & $stagedTesterPath $agentPath elevated 2>&1 | Out-File $outputPath -Append $exitCode = $LASTEXITCODE } catch { $_ | Out-File $outputPath -Append @@ -88,6 +94,7 @@ public static class AgentPolicyTesterNativeDirectory } catch { if ($attempt -eq 19) { "Failed to remove $stagingPath after 20 attempts: $_" | Out-File $outputPath -Append + $exitCode = 1 } else { Start-Sleep -Milliseconds 250 } diff --git a/crates/agent-policy-tester/run-unelevated.ps1 b/crates/agent-policy-tester/run-unelevated.ps1 new file mode 100644 index 000000000..b6615393c --- /dev/null +++ b/crates/agent-policy-tester/run-unelevated.ps1 @@ -0,0 +1,664 @@ +param( + [ValidateSet("Orchestrate", "Stage", "Server", "Run", "Signal", "Cleanup", "SelfTest")] + [string] $Action = "Orchestrate", + [string] $TesterPath, + [string] $StagedTesterPath, + [string] $StagingPath, + [string] $AgentPath, + [string] $TempPath, + [string] $ReadyPath, + [string] $StopPath, + [string] $StatusPath, + [string] $ServerOutputPath, + [string] $Nonce, + [string] $ExpectedClientSid +) + +$ErrorActionPreference = "Stop" + +function Test-ExplicitPsExecLaunchFailure { + param([string] $Diagnostics) + + return $Diagnostics -match '(?im)^(Couldn''t install PSEXESVC service:|Error establishing communication with PsExec service|Access is denied\.)' +} + +function Publish-ServerStatus { + param([string] $Path, [int] $ExitCode) + + $temporaryPath = "$Path.$([guid]::NewGuid().ToString('N')).tmp" + try { + [System.IO.File]::WriteAllText($temporaryPath, $ExitCode.ToString([System.Globalization.CultureInfo]::InvariantCulture)) + [System.IO.File]::Move($temporaryPath, $Path) + } finally { + if (Test-Path -LiteralPath $temporaryPath) { + Remove-Item -LiteralPath $temporaryPath -Force + } + } +} + +function Read-ServerStatus { + param([string] $Path) + + $text = [System.IO.File]::ReadAllText($Path) + $value = 0 + if ($text -notmatch '^-?[0-9]+$' -or -not [int]::TryParse($text, [ref] $value)) { + throw "LocalSystem test server published an invalid completion status" + } + return $value +} + +function Wait-ServerReadiness { + param( + [string] $Path, + [string] $ServerStatusPath, + [string] $ExpectedNonce, + [int] $TimeoutMilliseconds, + [int] $LaunchValue, + [string] $LaunchDiagnostics + ) + + if (Test-ExplicitPsExecLaunchFailure $LaunchDiagnostics) { + throw "LocalSystem test server launch failed (value $LaunchValue): $LaunchDiagnostics" + } + + $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMilliseconds) + while (-not (Test-Path -LiteralPath $Path)) { + if (Test-Path -LiteralPath $ServerStatusPath) { + $status = Read-ServerStatus -Path $ServerStatusPath + throw "LocalSystem test server exited with status $status before publishing readiness (launch value $LaunchValue): $LaunchDiagnostics" + } + if ([DateTime]::UtcNow -ge $deadline) { + throw "Timed out waiting for LocalSystem test server readiness (launch value $LaunchValue): $LaunchDiagnostics" + } + Start-Sleep -Milliseconds 100 + } + + $readiness = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($readiness.Nonce -cne $ExpectedNonce) { + throw "LocalSystem test server readiness nonce mismatch" + } + if ([string]::IsNullOrWhiteSpace($readiness.PipeName)) { + throw "LocalSystem test server readiness has no pipe name" + } + if ($readiness.ServerPid -le 0 -or $readiness.AgentPid -le 0 -or $readiness.ServerPid -eq $readiness.AgentPid) { + throw "LocalSystem test server readiness has invalid process identities" + } + if ($readiness.ServerSid -cne 'S-1-5-18' -or $readiness.AgentSid -cne 'S-1-5-18') { + throw "LocalSystem test server readiness has non-SYSTEM identities" + } + + return $readiness +} + +function Remove-StagingPath { + param([string] $Path) + + for ($attempt = 0; $attempt -lt 20 -and (Test-Path -LiteralPath $Path); $attempt++) { + try { + Remove-Item -LiteralPath $Path -Recurse -Force + } catch { + if ($attempt -eq 19) { + throw "Failed to remove $Path after 20 attempts: $_" + } + Start-Sleep -Milliseconds 250 + } + } + if (Test-Path -LiteralPath $Path) { + throw "Failed to remove $Path" + } +} + +function Complete-ServerShutdown { + param( + [bool] $ServerLaunchAttempted, + [bool] $ServerLaunchExplicitlyFailed, + [string] $StopPath, + [string] $StatusPath, + [string] $ServerOutputPath, + [string] $OutputPath, + [int] $ExitCode, + [scriptblock] $SignalServer + ) + + if (-not $ServerLaunchAttempted) { + return $ExitCode + } + + $signal = & $SignalServer + $signal.Output | Out-File $OutputPath -Append + if ($signal.ExitCode -ne 0 -or -not (Test-Path -LiteralPath $StopPath)) { + try { + New-Item -ItemType File -Path $StopPath -ErrorAction Stop | Out-Null + "Created the stop marker directly after LocalSystem signaling did not confirm it" | Out-File $OutputPath -Append + } catch { + $_ | Out-File $OutputPath -Append + } + } + if (-not (Test-Path -LiteralPath $StopPath)) { + "Failed to create the LocalSystem test server stop marker" | Out-File $OutputPath -Append + $ExitCode = 1 + } + + if (-not $ServerLaunchExplicitlyFailed) { + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while (-not (Test-Path -LiteralPath $StatusPath) -and [DateTime]::UtcNow -lt $deadline) { + Start-Sleep -Milliseconds 100 + } + if (Test-Path -LiteralPath $ServerOutputPath) { + Get-Content -LiteralPath $ServerOutputPath | Out-File $OutputPath -Append + } + if (Test-Path -LiteralPath $StatusPath) { + try { + $serverExitCode = Read-ServerStatus -Path $StatusPath + if ($serverExitCode -ne 0 -and $ExitCode -eq 0) { + $ExitCode = $serverExitCode + } + } catch { + $_ | Out-File $OutputPath -Append + $ExitCode = 1 + } + } else { + "Timed out waiting for LocalSystem test server shutdown" | Out-File $OutputPath -Append + $ExitCode = 1 + } + } + + return $ExitCode +} + +function New-RandomSecurePassword { + $alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*" + $bytes = [byte[]]::new(32) + [System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes) + $password = [System.Security.SecureString]::new() + foreach ($byte in $bytes) { + $password.AppendChar($alphabet[$byte % $alphabet.Length]) + } + foreach ($required in "Aa1!".ToCharArray()) { + $password.AppendChar($required) + } + $password.MakeReadOnly() + return $password +} + +function New-StandardUserAccount { + $name = "dgwpol$([guid]::NewGuid().ToString('N').Substring(0, 12))" + $password = New-RandomSecurePassword + try { + $user = New-LocalUser -Name $name -Password $password -AccountNeverExpires ` + -PasswordNeverExpires -UserMayNotChangePassword ` + -Description "Temporary Devolutions Agent policy E2E user" + $usersGroup = Get-LocalGroup -SID "S-1-5-32-545" + $isMember = Get-LocalGroupMember -Group $usersGroup -ErrorAction Stop | + Where-Object { $_.SID.Value -eq $user.SID.Value } + if (-not $isMember) { + Add-LocalGroupMember -Group $usersGroup -Member $user -ErrorAction Stop + } + return [pscustomobject]@{ + Name = $name + Sid = $user.SID.Value + Credential = [System.Management.Automation.PSCredential]::new( + $name, + $password + ) + } + } catch { + Remove-LocalUser -Name $name -ErrorAction SilentlyContinue + $password.Dispose() + throw + } +} + +function Set-StandardUserTempAcl { + param( + [string] $Path, + [string] $UserSid + ) + + New-Item -ItemType Directory -Path $Path -ErrorAction Stop | Out-Null + & icacls.exe $Path /inheritance:r /grant:r ` + '*S-1-5-18:(OI)(CI)(F)' ` + '*S-1-5-32-544:(OI)(CI)(F)' ` + "*$($UserSid):(OI)(CI)(M)" + if ($LASTEXITCODE -ne 0) { + throw "Failed to protect the standard-user temporary directory" + } +} + +function Invoke-StandardUserClient { + param( + [System.Management.Automation.PSCredential] $Credential, + [string] $UserSid, + [string] $ClientTempPath, + [string] $ScriptPath, + [string] $TesterExecutablePath, + [string] $AgentExecutablePath, + [string] $ReadinessPath, + [string] $ExpectedNonce + ) + + $startInfo = [System.Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = (Get-Command pwsh.exe -CommandType Application).Source + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $startInfo.LoadUserProfile = $false + $startInfo.UserName = $Credential.UserName + $startInfo.Domain = "." + $startInfo.Password = $Credential.Password + $startInfo.WorkingDirectory = $ClientTempPath + $startInfo.Environment["TEMP"] = $ClientTempPath + $startInfo.Environment["TMP"] = $ClientTempPath + foreach ($argument in @( + "-NoProfile", + "-File", + $ScriptPath, + "-Action", + "Run", + "-StagedTesterPath", + $TesterExecutablePath, + "-AgentPath", + $AgentExecutablePath, + "-TempPath", + $ClientTempPath, + "-ReadyPath", + $ReadinessPath, + "-Nonce", + $ExpectedNonce, + "-ExpectedClientSid", + $UserSid + )) { + $startInfo.ArgumentList.Add($argument) + } + + $process = [System.Diagnostics.Process]::new() + $process.StartInfo = $startInfo + try { + if (-not $process.Start()) { + throw "Failed to start the medium-integrity standard-user client" + } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(60000)) { + $process.Kill($true) + $process.WaitForExit() + throw "Timed out waiting for the medium-integrity standard-user client" + } + return [pscustomobject]@{ + ExitCode = $process.ExitCode + StdOut = $stdout.GetAwaiter().GetResult() + StdErr = $stderr.GetAwaiter().GetResult() + } + } finally { + $process.Dispose() + } +} + +function Remove-StandardUserAccount { + param( + [string] $Name, + [string] $Sid + ) + + for ($attempt = 0; $attempt -lt 20; $attempt++) { + try { + $profile = Get-CimInstance -ClassName Win32_UserProfile -Filter "SID='$Sid'" -ErrorAction Stop + if ($profile) { + $profile | Remove-CimInstance -ErrorAction Stop + } + if (Get-LocalUser -Name $Name -ErrorAction SilentlyContinue) { + Remove-LocalUser -Name $Name -ErrorAction Stop + } + if (-not (Get-LocalUser -Name $Name -ErrorAction SilentlyContinue)) { + return + } + } catch { + if ($attempt -eq 19) { + throw + } + } + Start-Sleep -Milliseconds 250 + } + throw "Temporary standard-user account still exists after 20 removal attempts" +} + +function Invoke-RunnerSelfTests { + $root = Join-Path ([System.IO.Path]::GetTempPath()) "agent-policy-runner-$([guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Path $root | Out-Null + try { + $ready = Join-Path $root "ready.json" + $status = Join-Path $root "status" + $serverOutput = Join-Path $root "server.out" + foreach ($expected in @(0, 1, -1)) { + Publish-ServerStatus -Path $status -ExitCode $expected + if ((Read-ServerStatus -Path $status) -ne $expected) { + throw "Completion-status round trip failed" + } + Remove-Item -LiteralPath $status + } + foreach ($invalid in @("", " ", "failed", "2147483648", "0`n1")) { + [System.IO.File]::WriteAllText($status, $invalid) + try { + Read-ServerStatus -Path $status | Out-Null + throw "Invalid completion status unexpectedly succeeded" + } catch { + if ($_ -notmatch "published an invalid completion status") { + throw + } + } + Remove-Item -LiteralPath $status + } + Set-Content -LiteralPath $ready -Value ( + @{ + Nonce = "nonce" + PipeName = "\\.\pipe\test" + ServerPid = 100 + ServerSid = "S-1-5-18" + AgentPid = 200 + AgentSid = "S-1-5-18" + } | ConvertTo-Json -Compress + ) + Wait-ServerReadiness -Path $ready -ServerStatusPath $status -ExpectedNonce "nonce" ` + -TimeoutMilliseconds 50 -LaunchValue 6256 -LaunchDiagnostics "started detached process" | Out-Null + + Remove-Item -LiteralPath $ready + try { + Wait-ServerReadiness -Path $ready -ServerStatusPath $status -ExpectedNonce "nonce" ` + -TimeoutMilliseconds 50 -LaunchValue 6256 -LaunchDiagnostics "started detached process" | Out-Null + throw "Missing-readiness simulation unexpectedly succeeded" + } catch { + if ( + $_ -notmatch "Timed out waiting for LocalSystem test server readiness" -or + $_ -notmatch "started detached process" + ) { + throw + } + } + + try { + Wait-ServerReadiness -Path $ready -ServerStatusPath $status -ExpectedNonce "nonce" ` + -TimeoutMilliseconds 5000 -LaunchValue 6 ` + -LaunchDiagnostics "Couldn't install PSEXESVC service: Access is denied." | Out-Null + throw "Explicit-launch-failure simulation unexpectedly succeeded" + } catch { + if ( + $_ -notmatch "LocalSystem test server launch failed" -or + $_ -notmatch "Couldn't install PSEXESVC service" + ) { + throw + } + } + + $launchAttempted = $true + Set-Content -LiteralPath $ready -Value ( + @{ + Nonce = "wrong-nonce" + PipeName = "\\.\pipe\test" + ServerPid = 100 + ServerSid = "S-1-5-18" + AgentPid = 200 + AgentSid = "S-1-5-18" + } | ConvertTo-Json -Compress + ) + try { + Wait-ServerReadiness -Path $ready -ServerStatusPath $status -ExpectedNonce "nonce" ` + -TimeoutMilliseconds 50 -LaunchValue 6256 -LaunchDiagnostics "started detached process" | Out-Null + throw "Mismatched-readiness simulation unexpectedly succeeded" + } catch { + if ($_ -notmatch "readiness nonce mismatch" -or -not $launchAttempted) { + throw + } + } + + Set-Content -LiteralPath $ready -Value ( + @{ + Nonce = "nonce" + PipeName = "\\.\pipe\test" + ServerPid = 100 + ServerSid = "S-1-5-18" + AgentPid = 100 + AgentSid = "S-1-5-18" + } | ConvertTo-Json -Compress + ) + try { + Wait-ServerReadiness -Path $ready -ServerStatusPath $status -ExpectedNonce "nonce" ` + -TimeoutMilliseconds 50 -LaunchValue 6256 -LaunchDiagnostics "started detached process" | Out-Null + throw "Invalid-PID readiness simulation unexpectedly succeeded" + } catch { + if ($_ -notmatch "readiness has invalid process identities" -or -not $launchAttempted) { + throw + } + } + + Remove-Item -LiteralPath $ready + try { + Wait-ServerReadiness -Path $ready -ServerStatusPath $status -ExpectedNonce "nonce" ` + -TimeoutMilliseconds 50 -LaunchValue 6256 -LaunchDiagnostics "started detached process" | Out-Null + throw "Attempted-launch missing-readiness simulation unexpectedly succeeded" + } catch { + if ($_ -notmatch "Timed out waiting for LocalSystem test server readiness" -or -not $launchAttempted) { + throw + } + } + + $stop = Join-Path $root "stop" + $shutdownOutput = Join-Path $root "shutdown.out" + $signalState = [pscustomobject]@{ Count = 0 } + [System.IO.File]::WriteAllText($status, "invalid") + $readinessFailureExitCode = Complete-ServerShutdown ` + -ServerLaunchAttempted $true -ServerLaunchExplicitlyFailed $false ` + -StopPath $stop -StatusPath $status -ServerOutputPath $serverOutput -OutputPath $shutdownOutput ` + -ExitCode 1 -SignalServer { + $signalState.Count++ + New-Item -ItemType File -Path $stop | Out-Null + [pscustomobject]@{ ExitCode = 0; Output = @("Simulated LocalSystem signal") } + } + if ( + $readinessFailureExitCode -ne 1 -or + $signalState.Count -ne 1 -or + -not (Test-Path -LiteralPath $stop) -or + (Get-Content -LiteralPath $shutdownOutput -Raw) -notmatch "published an invalid completion status" + ) { + throw "Readiness failure did not signal and verify LocalSystem server shutdown" + } + + Remove-StagingPath -Path (Join-Path $root "already-absent") + } finally { + Remove-StagingPath -Path $root + } +} + +if ($Action -eq "SelfTest") { + Invoke-RunnerSelfTests + exit 0 +} + +if ($Action -eq "Run") { + $env:TEMP = $TempPath + $env:TMP = $TempPath + & $StagedTesterPath $AgentPath standard-client $ExpectedClientSid $ReadyPath $Nonce + exit $LASTEXITCODE +} + +if ($Action -eq "Server") { + try { + & $StagedTesterPath $AgentPath standard-server $ReadyPath $StopPath $Nonce 2>&1 | + Out-File -LiteralPath $ServerOutputPath + $exitCode = $LASTEXITCODE + } catch { + $_ | Out-File -LiteralPath $ServerOutputPath -Append + $exitCode = 1 + } finally { + Publish-ServerStatus -Path $StatusPath -ExitCode $exitCode + } + exit $exitCode +} + +if ($Action -eq "Signal") { + if (-not (Test-Path -LiteralPath $StopPath)) { + New-Item -ItemType File -Path $StopPath -ErrorAction Stop | Out-Null + } + exit 0 +} + +if ($Action -eq "Cleanup") { + Remove-StagingPath -Path $StagingPath + exit 0 +} + +if ($Action -eq "Stage") { + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +public static class AgentPolicyStandardUserTesterDirectory +{ + [StructLayout(LayoutKind.Sequential)] + private struct SecurityAttributes + { + internal int Length; + internal IntPtr SecurityDescriptor; + internal int InheritHandle; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CreateDirectoryW(string path, ref SecurityAttributes securityAttributes); + + public static void Create(string path, byte[] securityDescriptor) + { + GCHandle pinnedDescriptor = GCHandle.Alloc(securityDescriptor, GCHandleType.Pinned); + try + { + SecurityAttributes attributes = new SecurityAttributes + { + Length = Marshal.SizeOf(), + SecurityDescriptor = pinnedDescriptor.AddrOfPinnedObject(), + InheritHandle = 0, + }; + if (!CreateDirectoryW(path, ref attributes)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + finally + { + pinnedDescriptor.Free(); + } + } +} +'@ + $directorySecurity = [System.Security.AccessControl.DirectorySecurity]::new() + $directorySecurity.SetSecurityDescriptorSddlForm( + 'O:SYG:SYD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)' + ) + [AgentPolicyStandardUserTesterDirectory]::Create( + $StagingPath, + $directorySecurity.GetSecurityDescriptorBinaryForm() + ) + if (Get-ChildItem -LiteralPath $StagingPath -Force) { + throw "The atomically protected staged tester directory was not empty" + } + + Copy-Item -LiteralPath $TesterPath -Destination $StagedTesterPath + & icacls.exe $StagedTesterPath /setowner '*S-1-5-18' + if ($LASTEXITCODE -ne 0) { + throw "Failed to set the staged tester owner" + } + & icacls.exe $StagedTesterPath /inheritance:r /grant:r '*S-1-5-18:(F)' '*S-1-5-32-544:(F)' '*S-1-5-32-545:(RX)' + if ($LASTEXITCODE -ne 0) { + throw "Failed to protect the staged tester executable" + } + Get-Acl -LiteralPath $StagingPath | Format-List Owner, Sddl + Get-Acl -LiteralPath $StagedTesterPath | Format-List Owner, Sddl + exit 0 +} + +$workspacePath = (Resolve-Path (Join-Path $PSScriptRoot "../..")).Path +$testerPath = Join-Path $workspacePath "target/debug/agent-policy-tester.exe" +$agentPath = Join-Path $workspacePath "target/debug/devolutions-agent.exe" +$outputPath = Join-Path $PSScriptRoot "agent-policy-tester-unelevated.out" +$stagingPath = Join-Path $env:ProgramData "dgw-agent-policy-tester-$([guid]::NewGuid().ToString('N'))" +$stagedTesterPath = Join-Path $stagingPath "agent-policy-tester.exe" +$readyPath = Join-Path $stagingPath "standard-user-ready.json" +$stopPath = Join-Path $stagingPath "standard-user-stop" +$statusPath = Join-Path $stagingPath "standard-user-server.status" +$serverOutputPath = Join-Path $stagingPath "standard-user-server.out" +$nonce = [guid]::NewGuid().ToString("N") +$exitCode = 1 +$serverLaunchAttempted = $false +$serverLaunchExplicitlyFailed = $false +$clientAccount = $null + +try { + Invoke-RunnerSelfTests + Set-Content -LiteralPath $outputPath -Value "" + + $stageOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath ` + -Action Stage -TesterPath $testerPath -StagedTesterPath $stagedTesterPath -StagingPath $stagingPath 2>&1 + $stageExitCode = $LASTEXITCODE + $stageOutput | Out-File $outputPath -Append + if ($stageExitCode -ne 0) { + throw "LocalSystem tester staging failed with exit code $stageExitCode" + } + + $clientAccount = New-StandardUserAccount + $clientTempPath = Join-Path $stagingPath "standard-user-temp" + Set-StandardUserTempAcl -Path $clientTempPath -UserSid $clientAccount.Sid + + $serverLaunchAttempted = $true + $serverOutput = & psexec.exe -accepteula -s -d pwsh.exe -NoProfile -File $PSCommandPath ` + -Action Server -StagedTesterPath $stagedTesterPath -AgentPath $agentPath -ReadyPath $readyPath ` + -StopPath $stopPath -StatusPath $statusPath -ServerOutputPath $serverOutputPath -Nonce $nonce 2>&1 + $serverStartExitCode = $LASTEXITCODE + $serverOutput | Out-File $outputPath -Append + "Detached server launch value: $serverStartExitCode" | Out-File $outputPath -Append + $serverLaunchDiagnostics = ($serverOutput | Out-String).Trim() + $serverLaunchExplicitlyFailed = Test-ExplicitPsExecLaunchFailure $serverLaunchDiagnostics + $readiness = Wait-ServerReadiness -Path $readyPath -ServerStatusPath $statusPath -ExpectedNonce $nonce ` + -TimeoutMilliseconds 30000 -LaunchValue $serverStartExitCode -LaunchDiagnostics $serverLaunchDiagnostics + $readiness | ConvertTo-Json -Compress | Out-File $outputPath -Append + + $client = Invoke-StandardUserClient -Credential $clientAccount.Credential -UserSid $clientAccount.Sid ` + -ClientTempPath $clientTempPath -ScriptPath $PSCommandPath -TesterExecutablePath $stagedTesterPath ` + -AgentExecutablePath $agentPath -ReadinessPath $readyPath -ExpectedNonce $nonce + $client.StdOut | Out-File $outputPath -Append + $client.StdErr | Out-File $outputPath -Append + $exitCode = $client.ExitCode +} catch { + $_ | Out-File $outputPath -Append + $exitCode = 1 +} finally { + $exitCode = Complete-ServerShutdown ` + -ServerLaunchAttempted $serverLaunchAttempted -ServerLaunchExplicitlyFailed $serverLaunchExplicitlyFailed ` + -StopPath $stopPath -StatusPath $statusPath -ServerOutputPath $serverOutputPath -OutputPath $outputPath ` + -ExitCode $exitCode -SignalServer { + $signalOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath ` + -Action Signal -StopPath $stopPath 2>&1 + [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $signalOutput } + } + + if ($clientAccount) { + try { + Remove-StandardUserAccount -Name $clientAccount.Name -Sid $clientAccount.Sid + } catch { + $_ | Out-File $outputPath -Append + if ($exitCode -eq 0) { + $exitCode = 1 + } + } finally { + $clientAccount.Credential.Password.Dispose() + } + } + + $cleanupOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath ` + -Action Cleanup -StagingPath $stagingPath 2>&1 + $cleanupExitCode = $LASTEXITCODE + $cleanupOutput | Out-File $outputPath -Append + if ($cleanupExitCode -ne 0 -and $exitCode -eq 0) { + $exitCode = $cleanupExitCode + } +} + +exit $exitCode diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index 26ac51f30..c7511892d 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -1,3 +1,6 @@ +use std::fs::OpenOptions; +use std::io::Write as _; +use std::mem::size_of; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::{Duration, Instant}; @@ -6,14 +9,27 @@ use anyhow::{Context as _, bail, ensure}; use serde_json::{Value, json}; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use tokio::net::windows::named_pipe::ClientOptions; +use win_api_wrappers::identity::sid::Sid; +use win_api_wrappers::process::Process; +use windows::Win32::Foundation::{CloseHandle, HANDLE}; +use windows::Win32::Security::{ + GetSidSubAuthority, GetSidSubAuthorityCount, GetTokenInformation, TOKEN_DUPLICATE, TOKEN_MANDATORY_LABEL, + TOKEN_QUERY, TokenIntegrityLevel, WinBuiltinAdministratorsSid, WinLocalSystemSid, +}; +use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION}; const FULL_POLICY: &str = include_str!("../../now-package-broker/src/assets/samples/corporate-allowlist.policy.json"); +const MANAGED_POLICY_RELATIVE_PATH: &str = r"Devolutions\PackageBroker\package-broker-policy.json"; +#[cfg(test)] +const SECURITY_MANDATORY_LOW_RID: u32 = 0x1000; +const SECURITY_MANDATORY_MEDIUM_RID: u32 = 0x2000; struct AgentHarness { child: tokio::process::Child, - _data_dir: tempfile::TempDir, + data_dir: tempfile::TempDir, pipe_name: String, policy_path: PathBuf, + program_data: Option, } impl AgentHarness { @@ -34,17 +50,43 @@ impl AgentHarness { Self::start_with_path(agent_path, data_dir, pipe_name, policy_path).await } + async fn start_unelevated(agent_path: &Path) -> anyhow::Result { + let data_dir = tempfile::tempdir().context("create unelevated Agent data directory")?; + let pipe_name = unique_pipe_name(); + let policy_path = data_dir.path().join("policy.json"); + Self::start_with_options(agent_path, data_dir, pipe_name, policy_path, false).await + } + + async fn start_managed_default(agent_path: &Path) -> anyhow::Result { + let data_dir = create_data_dir()?; + let pipe_name = unique_pipe_name(); + let policy_path = data_dir.path().join(MANAGED_POLICY_RELATIVE_PATH); + Self::start_with_options(agent_path, data_dir, pipe_name, policy_path, true).await + } + async fn start_with_path( agent_path: &Path, data_dir: tempfile::TempDir, pipe_name: String, policy_path: PathBuf, ) -> anyhow::Result { + Self::start_with_options(agent_path, data_dir, pipe_name, policy_path, false).await + } + + async fn start_with_options( + agent_path: &Path, + data_dir: tempfile::TempDir, + pipe_name: String, + policy_path: PathBuf, + use_managed_default: bool, + ) -> anyhow::Result { + let policy_path_config = (!use_managed_default).then_some(&policy_path); let config = json!({ + "LogFile": data_dir.path().join("agent-e2e"), "PackageBroker": { "Enabled": true, "PipeName": pipe_name, - "PolicyPath": policy_path, + "PolicyPath": policy_path_config, }, "__debug__": { "skip_broker_signature_validation": true, @@ -53,26 +95,35 @@ impl AgentHarness { std::fs::write(data_dir.path().join("agent.json"), serde_json::to_vec_pretty(&config)?) .context("write Agent configuration")?; - let child = tokio::process::Command::new(agent_path) - .env("DAGENT_CONFIG_PATH", data_dir.path()) - .arg("run") - .kill_on_drop(true) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .context("start Devolutions Agent")?; + let program_data = use_managed_default.then(|| data_dir.path().to_owned()); + let child = Self::spawn(agent_path, data_dir.path(), program_data.as_deref())?; let mut harness = Self { child, - _data_dir: data_dir, + data_dir, pipe_name, policy_path, + program_data, }; harness.wait_until_ready().await?; Ok(harness) } + fn spawn(agent_path: &Path, data_dir: &Path, program_data: Option<&Path>) -> anyhow::Result { + let mut command = tokio::process::Command::new(agent_path); + command + .env("DAGENT_CONFIG_PATH", data_dir) + .arg("run") + .kill_on_drop(true) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + if let Some(program_data) = program_data { + command.env("ProgramData", program_data); + } + command.spawn().context("start Devolutions Agent") + } + async fn wait_until_ready(&mut self) -> anyhow::Result<()> { let deadline = Instant::now() + Duration::from_secs(20); @@ -89,6 +140,34 @@ impl AgentHarness { } } } + + async fn restart(&mut self, agent_path: &Path) -> anyhow::Result<()> { + self.stop().await?; + self.start_again(agent_path).await + } + + async fn stop(&mut self) -> anyhow::Result<()> { + self.child.start_kill().context("stop Devolutions Agent")?; + self.child.wait().await.context("wait for Devolutions Agent to stop")?; + Ok(()) + } + + async fn start_again(&mut self, agent_path: &Path) -> anyhow::Result<()> { + self.child = Self::spawn(agent_path, self.data_dir.path(), self.program_data.as_deref())?; + self.wait_until_ready().await + } + + fn logs(&self) -> anyhow::Result { + let mut logs = String::new(); + for entry in std::fs::read_dir(self.data_dir.path()).context("read Agent log directory")? { + let entry = entry.context("read Agent log entry")?; + let name = entry.file_name(); + if name.to_string_lossy().starts_with("agent-e2e") { + logs.push_str(&std::fs::read_to_string(entry.path()).context("read Agent log")?); + } + } + Ok(logs) + } } impl Drop for AgentHarness { @@ -108,25 +187,222 @@ impl HttpResponse { } } +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mode { + StandardServer, + StandardClient, + Elevated, +} + +impl Mode { + fn parse(value: &str) -> anyhow::Result { + match value { + "standard-server" => Ok(Self::StandardServer), + "standard-client" => Ok(Self::StandardClient), + "elevated" => Ok(Self::Elevated), + _ => bail!("unknown mode '{value}'; expected 'standard-server', 'standard-client', or 'elevated'"), + } + } +} + pub(crate) async fn run() -> anyhow::Result<()> { - let agent_path = std::env::args_os() - .nth(1) + let mut args = std::env::args_os().skip(1); + let agent_path = args + .next() .map(PathBuf::from) - .context("usage: agent-policy-tester ")?; + .context("usage: agent-policy-tester [mode arguments]")?; + let mode = args + .next() + .and_then(|value| value.into_string().ok()) + .context("test mode is required") + .and_then(|value| Mode::parse(&value))?; + match mode { + Mode::StandardServer => { + verify_local_system()?; + ensure_agent_path(&agent_path)?; + let ready_path = next_path(&mut args, "ready path")?; + let stop_path = next_path(&mut args, "stop path")?; + let nonce = next_string(&mut args, "coordination nonce")?; + ensure!(args.next().is_none(), "unexpected standard-server arguments"); + standard_user_server(&agent_path, &ready_path, &stop_path, &nonce).await?; + } + Mode::StandardClient => { + let expected_sid = next_string(&mut args, "expected client SID")?; + let client = verify_standard_user(&expected_sid)?; + let ready_path = next_path(&mut args, "ready path")?; + let nonce = next_string(&mut args, "coordination nonce")?; + ensure!(args.next().is_none(), "unexpected standard-client arguments"); + standard_user_management(&ready_path, &nonce, &client).await?; + } + Mode::Elevated => { + verify_local_system()?; + ensure_agent_path(&agent_path)?; + ensure!(args.next().is_none(), "unexpected elevated arguments"); + unavailable_policy_and_method_restrictions(&agent_path).await?; + complete_snapshots_across_reload(&agent_path).await?; + redirected_policy_paths_fail_closed(&agent_path).await?; + management_write_tokens_survive_watcher_reload(&agent_path).await?; + managed_policy_lifecycle(&agent_path).await?; + interrupted_malformed_repair(&agent_path).await?; + } + } + + Ok(()) +} + +fn ensure_agent_path(agent_path: &Path) -> anyhow::Result<()> { ensure!( agent_path.is_file(), "agent executable does not exist: {}", agent_path.display() ); + Ok(()) +} - unavailable_policy_and_method_restrictions(&agent_path).await?; - complete_snapshots_across_reload(&agent_path).await?; - redirected_policy_paths_fail_closed(&agent_path).await?; - management_write_tokens_survive_watcher_reload(&agent_path).await?; +struct ProcessIdentity { + pid: u32, + sid: Sid, +} +fn current_process_identity() -> anyhow::Result<(ProcessIdentity, bool, bool)> { + let token = Process::current_process() + .token(TOKEN_QUERY | TOKEN_DUPLICATE) + .context("open tester process token")?; + let administrators = + Sid::from_well_known(WinBuiltinAdministratorsSid, None).context("construct Administrators SID")?; + let is_administrator = token + .is_member(&administrators) + .context("query tester Administrators membership")?; + let identity = ProcessIdentity { + pid: std::process::id(), + sid: token.sid_and_attributes().context("query tester user SID")?.sid, + }; + Ok(( + identity, + is_administrator, + token.is_elevated().context("query tester token elevation")?, + )) +} + +fn verify_standard_user(expected_sid: &str) -> anyhow::Result { + let (identity, is_administrator, _) = current_process_identity()?; + validate_standard_user_token( + &identity.sid.to_string(), + expected_sid, + is_administrator, + current_integrity_level()?, + )?; + Ok(identity) +} + +fn validate_standard_user_token( + actual_sid: &str, + expected_sid: &str, + is_administrator: bool, + integrity_level: u32, +) -> anyhow::Result<()> { + ensure!(actual_sid == expected_sid, "standard-client account SID mismatch"); + ensure!( + !is_administrator, + "standard-client mode requires disabled Administrators membership" + ); + ensure!( + integrity_level == SECURITY_MANDATORY_MEDIUM_RID, + "standard-client mode requires Medium integrity, got RID {integrity_level:#x}" + ); Ok(()) } +fn current_integrity_level() -> anyhow::Result { + let mut token = HANDLE::default(); + // SAFETY: `GetCurrentProcess` has no preconditions and returns a process pseudo-handle. + let process = unsafe { GetCurrentProcess() }; + // SAFETY: The process pseudo-handle is valid and `token` is a writable output parameter. + unsafe { + OpenProcessToken(process, TOKEN_QUERY, &mut token).context("open current process integrity token")?; + } + let result = integrity_level(token); + // SAFETY: `OpenProcessToken` returned this owned token handle. + unsafe { + CloseHandle(token).context("close current process integrity token")?; + } + result +} + +fn integrity_level(token: HANDLE) -> anyhow::Result { + let mut length = 0; + // SAFETY: A null output buffer with length zero is the documented size query. + let _ = unsafe { GetTokenInformation(token, TokenIntegrityLevel, None, 0, &mut length) }; + ensure!( + usize::try_from(length)? >= size_of::(), + "TokenIntegrityLevel returned an undersized buffer" + ); + + let word_count = usize::try_from(length)?.div_ceil(size_of::()); + let mut buffer = vec![0usize; word_count]; + // SAFETY: The aligned buffer is writable for `length` bytes and the token handle is valid. + unsafe { + GetTokenInformation( + token, + TokenIntegrityLevel, + Some(buffer.as_mut_ptr().cast()), + length, + &mut length, + ) + .context("query token integrity level")?; + } + // SAFETY: A successful TokenIntegrityLevel query initialized a TOKEN_MANDATORY_LABEL. + let label = unsafe { &*buffer.as_ptr().cast::() }; + // SAFETY: The returned token label contains a valid SID. + let sub_authority_count_ptr = unsafe { GetSidSubAuthorityCount(label.Label.Sid) }; + // SAFETY: `GetSidSubAuthorityCount` returns a pointer into the valid label SID. + let sub_authority_count = unsafe { *sub_authority_count_ptr }; + ensure!(sub_authority_count > 0, "integrity SID has no sub-authority"); + // SAFETY: The index is within the validated SID sub-authority count. + let rid_ptr = unsafe { GetSidSubAuthority(label.Label.Sid, u32::from(sub_authority_count - 1)) }; + // SAFETY: `GetSidSubAuthority` returns a pointer into the valid label SID. + let rid = unsafe { *rid_ptr }; + Ok(rid) +} + +fn verify_local_system() -> anyhow::Result { + let (identity, is_administrator, is_elevated) = current_process_identity()?; + let system = Sid::from_well_known(WinLocalSystemSid, None).context("construct LocalSystem SID")?; + ensure!(identity.sid == system, "this mode requires the LocalSystem account"); + ensure!(is_administrator && is_elevated, "LocalSystem token is not elevated"); + Ok(identity) +} + +fn process_sid(pid: u32) -> anyhow::Result { + Process::get_by_pid(pid, PROCESS_QUERY_LIMITED_INFORMATION) + .with_context(|| format!("open process {pid}"))? + .token(TOKEN_QUERY) + .with_context(|| format!("open process {pid} token"))? + .sid_and_attributes() + .with_context(|| format!("query process {pid} SID")) + .map(|identity| identity.sid) +} + +fn next_path(args: &mut impl Iterator, name: &str) -> anyhow::Result { + args.next() + .map(PathBuf::from) + .with_context(|| format!("missing {name}")) +} + +fn next_string(args: &mut impl Iterator, name: &str) -> anyhow::Result { + args.next() + .and_then(|value| value.into_string().ok()) + .with_context(|| format!("missing or non-Unicode {name}")) +} + +fn unique_pipe_name() -> String { + format!( + r"\\.\pipe\Devolutions.Now.PackageBroker.tests.{}.{}", + std::process::id(), + fastrand::u64(..) + ) +} + async fn request(pipe_name: &str, method: &str, path: &str) -> anyhow::Result { request_with_body(pipe_name, method, path, None, &[]).await } @@ -205,7 +481,49 @@ fn policy_draft(id: &str, publisher: &str) -> Value { "PolicyFormatVersion": "1.0.0", "Metadata": { "Id": id, "Publisher": publisher }, "Enforcement": { "DefaultDecision": "Deny" }, - "Rules": [] + "Rules": [ + { + "Id": "allow.exact", + "Priority": 100, + "Decision": "Allow", + "Match": { + "Operations": ["Install"], + "Managers": ["Winget"], + "SourceNames": ["winget"], + "PackageIdentifiers": { "Exact": ["Microsoft.PowerToys"] }, + "Version": { "Exact": ["0.86.0"] }, + "ExecutionElevation": ["Elevated"], + "Interactive": true, + "SkipHashCheck": false + }, + "Constraints": { + "AllowInteractive": true, + "AllowSkipHashCheck": false + } + }, + { + "Id": "allow.pattern", + "Priority": 101, + "Decision": "Allow", + "Match": { + "Managers": ["Winget"], + "SourceNames": ["winget"], + "PackageIdentifiers": { "Patterns": ["Contoso.*"] }, + "Version": { + "Range": { + "MinVersion": "1.0.0", + "MaxVersion": "2.0.0", + "IncludePrerelease": false + } + }, + "ExecutionElevation": ["Standard"], + "HasCustomParameters": false + }, + "Constraints": { + "AllowCustomParameters": false + } + } + ] }) } @@ -307,7 +625,6 @@ async fn assert_redirected_policy_rejected( "ExpectedStoreToken": management["Management"]["StoreToken"], "Operation": "Repair", "ConflictHandling": "Reject", - "WarningsAcknowledged": false, "Draft": full_policy(), "ValidationReceipt": "invalid" }); @@ -352,7 +669,11 @@ async fn redirected_policy_paths_fail_closed(agent_path: &Path) -> anyhow::Resul } async fn policy_management(agent: &AgentHarness) -> anyhow::Result { - let response = request(&agent.pipe_name, "GET", "/v1/policy/management").await?; + policy_management_by_pipe(&agent.pipe_name).await +} + +async fn policy_management_by_pipe(pipe_name: &str) -> anyhow::Result { + let response = request(pipe_name, "GET", "/v1/policy/management").await?; ensure!( response.status == 200, "GET /v1/policy/management returned HTTP {}", @@ -361,19 +682,14 @@ async fn policy_management(agent: &AgentHarness) -> anyhow::Result { Ok(response.json()?["Management"].clone()) } -async fn replace_policy( - agent: &AgentHarness, - operation: &str, - expected_store_token: Value, - draft: Value, -) -> anyhow::Result { +async fn validate_policy_by_pipe(pipe_name: &str, draft: &Value) -> anyhow::Result { let validation_request = json!({ "RequestKind": "PolicyValidationRequest", "RequestVersion": "1.0", "Draft": draft }); let validation_response = request_with_body( - &agent.pipe_name, + pipe_name, "POST", "/v1/policy/validate", Some("application/json"), @@ -387,24 +703,72 @@ async fn replace_policy( ); let validation = validation_response.json()?["Validation"].clone(); ensure!(validation["IsValid"] == true, "policy validation failed"); + ensure!( + validation["ValidatorVersion"] == "now-package-broker-policy-validator/10", + "unexpected validator contract" + ); + ensure!( + validation["CanonicalDraft"]["PolicyFormatVersion"] == draft["PolicyFormatVersion"], + "canonical draft changed the format version" + ); + Ok(validation) +} + +async fn send_replacement(pipe_name: &str, replacement: &Value) -> anyhow::Result { + request_with_body( + pipe_name, + "PUT", + "/v1/policy", + Some("application/json"), + &serde_json::to_vec(replacement)?, + ) + .await +} + +async fn replace_policy_response( + agent: &AgentHarness, + operation: &str, + conflict_handling: &str, + expected_store_token: Value, + draft: Value, +) -> anyhow::Result { + replace_policy_response_by_pipe( + &agent.pipe_name, + operation, + conflict_handling, + expected_store_token, + draft, + ) + .await +} + +async fn replace_policy_response_by_pipe( + pipe_name: &str, + operation: &str, + conflict_handling: &str, + expected_store_token: Value, + draft: Value, +) -> anyhow::Result { + let validation = validate_policy_by_pipe(pipe_name, &draft).await?; let replacement_request = json!({ "RequestKind": "PolicyReplacementRequest", "RequestVersion": "1.0", "ExpectedStoreToken": expected_store_token, "Operation": operation, - "ConflictHandling": "Reject", - "WarningsAcknowledged": true, + "ConflictHandling": conflict_handling, "Draft": validation["CanonicalDraft"], "ValidationReceipt": validation["ValidationReceipt"] }); - let response = request_with_body( - &agent.pipe_name, - "PUT", - "/v1/policy", - Some("application/json"), - &serde_json::to_vec(&replacement_request)?, - ) - .await?; + send_replacement(pipe_name, &replacement_request).await +} + +async fn replace_policy( + agent: &AgentHarness, + operation: &str, + expected_store_token: Value, + draft: Value, +) -> anyhow::Result { + let response = replace_policy_response(agent, operation, "Reject", expected_store_token, draft).await?; ensure!(response.status == 200, "{operation} returned HTTP {}", response.status); response.json() } @@ -461,6 +825,496 @@ async fn management_write_tokens_survive_watcher_reload(agent_path: &Path) -> an Ok(()) } +async fn standard_user_server( + agent_path: &Path, + ready_path: &Path, + stop_path: &Path, + nonce: &str, +) -> anyhow::Result<()> { + ensure!(!ready_path.exists(), "standard-user readiness path already exists"); + ensure!(!stop_path.exists(), "standard-user stop path already exists"); + + let mut agent = AgentHarness::start_unelevated(agent_path).await?; + let server = verify_local_system()?; + let agent_pid = agent.child.id().context("Agent process has no PID")?; + let child_sid = process_sid(agent_pid)?; + ensure!( + child_sid == server.sid, + "Agent and test server must both run as LocalSystem" + ); + + let readiness = serde_json::to_vec(&json!({ + "Nonce": nonce, + "PipeName": agent.pipe_name, + "ServerPid": server.pid, + "ServerSid": server.sid.to_string(), + "AgentPid": agent_pid, + "AgentSid": child_sid.to_string(), + }))?; + let ready_temp_path = ready_path.with_extension("tmp"); + let mut ready_file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&ready_temp_path) + .context("create standard-user readiness temporary file")?; + ready_file + .write_all(&readiness) + .context("write standard-user readiness temporary file")?; + ready_file + .sync_all() + .context("flush standard-user readiness temporary file")?; + drop(ready_file); + std::fs::rename(&ready_temp_path, ready_path).context("publish standard-user readiness file")?; + + let deadline = Instant::now() + Duration::from_secs(90); + while !stop_path.exists() { + if let Some(status) = agent.child.try_wait().context("query Agent status")? { + bail!("Agent exited during standard-user test with {status}"); + } + ensure!( + Instant::now() < deadline, + "timed out waiting for standard-user client completion" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + let result = wait_for_log(&agent, "Policy management write denied").await; + agent.stop().await?; + result +} + +async fn standard_user_management(ready_path: &Path, nonce: &str, client: &ProcessIdentity) -> anyhow::Result<()> { + let readiness: Value = + serde_json::from_slice(&std::fs::read(ready_path).context("read standard-user readiness file")?) + .context("parse standard-user readiness file")?; + ensure!(readiness["Nonce"] == nonce, "standard-user readiness nonce mismatch"); + let pipe_name = readiness["PipeName"] + .as_str() + .context("readiness file has no pipe name")?; + let server_pid = readiness["ServerPid"] + .as_u64() + .and_then(|pid| u32::try_from(pid).ok()) + .context("readiness file has no valid server PID")?; + let agent_pid = readiness["AgentPid"] + .as_u64() + .and_then(|pid| u32::try_from(pid).ok()) + .context("readiness file has no valid Agent PID")?; + let system = Sid::from_well_known(WinLocalSystemSid, None) + .context("construct LocalSystem SID")? + .to_string(); + ensure!( + readiness["ServerSid"] == system && readiness["AgentSid"] == system, + "test server and Agent identities were not recorded as LocalSystem" + ); + ensure!( + client.pid != server_pid && client.pid != agent_pid && server_pid != agent_pid, + "standard-user client, test server, and Agent must be distinct processes" + ); + ensure!( + client.sid.to_string() != system, + "standard-user client unexpectedly uses the server identity" + ); + + let management = policy_management_by_pipe(pipe_name).await?; + ensure!(management["State"] == "Missing", "expected a missing policy"); + let missing_policy = request(pipe_name, "GET", "/v1/policy").await?; + ensure!( + missing_policy.status == 404 + && missing_policy.json()?["Code"] == "NotFound" + && missing_policy.json()?["Message"] == "active policy is unavailable", + "standard-user missing policy read did not return the canonical not-found response" + ); + + let valid_draft = policy_draft("tests.standard-user", "Test"); + let validation = validate_policy_by_pipe(pipe_name, &valid_draft).await?; + ensure!( + validation["CanonicalDraft"].is_object() && validation["ValidationReceipt"].is_string(), + "valid draft did not produce a canonical draft and receipt" + ); + + strict_contract_validation(pipe_name).await?; + + for operation in ["Create", "Update", "Repair", "ReplaceIdentity"] { + let denied = replace_policy_response_by_pipe( + pipe_name, + operation, + "Reject", + management["StoreToken"].clone(), + valid_draft.clone(), + ) + .await?; + ensure!( + denied.status == 403 && denied.json()?["Code"] == "AdministratorRequired", + "standard-user {operation} did not require an administrator" + ); + } + ensure!( + policy_management_by_pipe(pipe_name).await?["StoreToken"] == management["StoreToken"], + "denied writes changed the store token" + ); + Ok(()) +} + +async fn strict_contract_validation(pipe_name: &str) -> anyhow::Result<()> { + for version in ["1.0.0", "1.7.3"] { + let mut draft = policy_draft("tests.contract", "Contract"); + draft["PolicyFormatVersion"] = json!(version); + let validation = validate_policy_by_pipe(pipe_name, &draft).await?; + let canonical = &validation["CanonicalDraft"]; + ensure!( + canonical["Rules"][0]["Match"]["Managers"] == json!(["Winget"]) + && canonical["Rules"][0]["Match"]["SourceNames"] == json!(["winget"]) + && canonical["Rules"][0]["Match"]["PackageIdentifiers"]["Exact"] == json!(["Microsoft.PowerToys"]) + && canonical["Rules"][0]["Match"]["Version"]["Exact"] == json!(["0.86.0"]) + && canonical["Rules"][0]["Match"]["ExecutionElevation"] == json!(["Elevated"]) + && canonical["Rules"][1]["Match"]["PackageIdentifiers"]["Patterns"] == json!(["Contoso.*"]) + && canonical["Rules"][1]["Match"]["Version"]["Range"]["MinVersion"] == "1.0.0" + && canonical["Rules"][1]["Match"]["ExecutionElevation"] == json!(["Standard"]), + "canonical draft did not preserve final rule shapes" + ); + ensure!( + canonical["Rules"][0]["Match"].get("PreRelease").is_none() + && canonical["Rules"][1]["Match"].get("Interactive").is_none(), + "canonical draft did not omit absent optional characteristics" + ); + } + for (value, expected_path) in [("2.0.0", "/PolicyFormatVersion"), ("broken", "/PolicyFormatVersion")] { + let mut draft = policy_draft("tests.contract", "Contract"); + draft["PolicyFormatVersion"] = json!(value); + assert_invalid_draft(pipe_name, draft, expected_path).await?; + } + let mut invalid_interval = policy_draft("tests.contract", "Contract"); + invalid_interval["Metadata"]["ValidFrom"] = json!("2026-01-01T00:00:00Z"); + invalid_interval["Metadata"]["ValidUntil"] = json!("2026-01-01T00:00:00Z"); + assert_invalid_draft(pipe_name, invalid_interval, "/Metadata/ValidUntil").await?; + + let mut duplicate_rule = policy_draft("tests.contract", "Contract"); + duplicate_rule["Rules"][1]["Id"] = duplicate_rule["Rules"][0]["Id"].clone(); + assert_invalid_draft(pipe_name, duplicate_rule, "/Rules/1/Id").await?; + Ok(()) +} + +async fn assert_invalid_draft(pipe_name: &str, draft: Value, expected_path: &str) -> anyhow::Result<()> { + let invalid_request = json!({ + "RequestKind": "PolicyValidationRequest", + "RequestVersion": "1.0", + "Draft": draft + }); + let invalid_response = request_with_body( + pipe_name, + "POST", + "/v1/policy/validate", + Some("application/json"), + &serde_json::to_vec(&invalid_request)?, + ) + .await?; + ensure!( + invalid_response.status == 200, + "invalid draft validation returned HTTP {}", + invalid_response.status + ); + let invalid_validation = invalid_response.json()?["Validation"].clone(); + ensure!(invalid_validation["IsValid"] == false, "invalid draft was accepted"); + ensure!( + invalid_validation.get("CanonicalDraft").is_none() + && invalid_validation.get("ValidationReceipt").is_none() + && invalid_validation["ValidatorVersion"] == "now-package-broker-policy-validator/10", + "invalid draft returned a canonical draft, receipt, or wrong validator version" + ); + ensure!( + invalid_validation["Findings"] + .as_array() + .is_some_and(|findings| findings + .iter() + .any(|finding| finding["Severity"] == "Error" && finding["Path"] == expected_path)), + "invalid draft did not report {expected_path}: {invalid_validation}" + ); + Ok(()) +} + +async fn managed_policy_lifecycle(agent_path: &Path) -> anyhow::Result<()> { + let mut agent = AgentHarness::start_managed_default(agent_path).await?; + strict_contract_validation(&agent.pipe_name).await?; + let initial = policy_management(&agent).await?; + ensure!( + initial["State"] == "Missing", + "managed policy was not initially missing" + ); + ensure!( + initial["Source"] == "DefaultPath" && initial["WriteCapability"] == "Writable", + "isolated managed default path was not writable" + ); + + let created = replace_policy( + &agent, + "Create", + initial["StoreToken"].clone(), + policy_draft("tests.managed-lifecycle", "Create"), + ) + .await?; + ensure!( + created["Policy"]["Metadata"]["Revision"] == 1, + "Create did not assign revision 1" + ); + wait_for_log(&agent, "Policy creation succeeded").await?; + + let updated = replace_policy( + &agent, + "Update", + created["Management"]["StoreToken"].clone(), + policy_draft("tests.managed-lifecycle", "Update"), + ) + .await?; + ensure!( + updated["Policy"]["Metadata"]["Revision"] == 2, + "Update did not increment the revision" + ); + + let secret = "malformed-policy-secret-marker"; + std::fs::write(&agent.policy_path, format!(r#"{{"unterminated":"{secret}"#)) + .context("write malformed external policy")?; + let invalid = wait_for_management(&agent, |management| management["State"] == "Invalid").await?; + let diagnostics = &invalid["InvalidDiagnostics"]; + ensure!( + diagnostics["Findings"] + .as_array() + .is_some_and(|findings| !findings.is_empty()), + "invalid policy did not produce diagnostics" + ); + ensure!( + !diagnostics.to_string().contains(secret), + "invalid policy diagnostics exposed file contents" + ); + wait_for_log(&agent, "External policy change rejected").await?; + + let repaired = replace_policy( + &agent, + "Repair", + invalid["StoreToken"].clone(), + policy_draft("tests.managed-repaired", "Repair"), + ) + .await?; + ensure!( + repaired["Policy"]["Metadata"]["Revision"] == 1, + "Repair did not assign revision 1" + ); + + let stale_token = repaired["Management"]["StoreToken"].clone(); + let mut external = empty_policy(); + external["Metadata"]["Id"] = json!("tests.managed-external"); + std::fs::write(&agent.policy_path, serde_json::to_vec_pretty(&external)?).context("write valid external policy")?; + wait_for_management(&agent, |management| { + management["Policy"]["Metadata"]["Id"] == "tests.managed-external" + }) + .await?; + wait_for_log(&agent, "External policy change applied").await?; + + let stale = replace_policy_response( + &agent, + "Update", + "Reject", + stale_token.clone(), + policy_draft("tests.managed-external", "Stale"), + ) + .await?; + ensure!(stale.status == 409, "stale Update returned HTTP {}", stale.status); + let stale = stale.json()?; + ensure!( + stale["Code"] == "StalePolicyStoreToken" + && stale["Management"]["Policy"]["Metadata"]["Id"] == "tests.managed-external", + "stale Update did not return the current policy snapshot" + ); + wait_for_log(&agent, "stale_conflict").await?; + + let stale_confirm = replace_policy_response( + &agent, + "Update", + "ConfirmOverwrite", + stale_token, + policy_draft("tests.managed-external", "Stale confirmed overwrite"), + ) + .await?; + ensure!( + stale_confirm.status == 409 && stale_confirm.json()?["Code"] == "StalePolicyStoreToken", + "stale ConfirmOverwrite did not return a store token conflict" + ); + + let current_token = stale["Management"]["StoreToken"].clone(); + let confirmed = replace_policy_response( + &agent, + "Update", + "ConfirmOverwrite", + current_token.clone(), + policy_draft("tests.managed-external", "Confirmed overwrite"), + ) + .await?; + ensure!( + confirmed.status == 200, + "exact ConfirmOverwrite returned HTTP {}", + confirmed.status + ); + let confirmed = confirmed.json()?; + ensure!( + confirmed["Policy"]["Metadata"]["Revision"] == 2, + "confirmed Update did not increment the external policy revision" + ); + wait_for_log(&agent, "confirmed_overwrite").await?; + + let reused = replace_policy_response( + &agent, + "Update", + "ConfirmOverwrite", + current_token, + policy_draft("tests.managed-external", "Reused token"), + ) + .await?; + ensure!( + reused.status == 409 && reused.json()?["Code"] == "StalePolicyStoreToken", + "reused ConfirmOverwrite token did not conflict" + ); + + let confirmed = warnings_identity_and_receipts(&mut agent, agent_path, &confirmed).await?; + agent.restart(agent_path).await?; + let restarted = request(&agent.pipe_name, "GET", "/v1/policy").await?; + ensure!( + restarted.status == 200, + "policy read after restart returned HTTP {}", + restarted.status + ); + ensure!( + restarted.json()?["Policy"] == confirmed["Policy"], + "restart changed the active managed policy" + ); + Ok(()) +} + +async fn warnings_identity_and_receipts( + agent: &mut AgentHarness, + agent_path: &Path, + current: &Value, +) -> anyhow::Result { + let mut draft = policy_draft("tests.replaced-identity", "Canonical contract"); + draft["PolicyFormatVersion"] = json!("1.7.3"); + draft["Enforcement"]["AuditMode"] = json!(true); + let validation = validate_policy_by_pipe(&agent.pipe_name, &draft).await?; + ensure!( + validation["Findings"].as_array().is_some_and(|findings| findings + .iter() + .any(|finding| finding["Code"] == "AuditModeEnabled" && finding["Severity"] == "Warning")), + "audit-mode draft did not produce its warning" + ); + let mut replacement = json!({ + "RequestKind": "PolicyReplacementRequest", + "RequestVersion": "1.0", + "ExpectedStoreToken": current["Management"]["StoreToken"], + "Operation": "ReplaceIdentity", + "ConflictHandling": "Reject", + "Draft": validation["CanonicalDraft"], + "ValidationReceipt": validation["ValidationReceipt"] + }); + replacement["Draft"]["Metadata"]["Publisher"] = json!("Tampered after validation"); + let tampered = send_replacement(&agent.pipe_name, &replacement).await?; + ensure!( + tampered.status == 422 && tampered.json()?["Code"] == "ValidationFailed", + "receipt authorized a different draft" + ); + replacement["Draft"] = validation["CanonicalDraft"].clone(); + agent.restart(agent_path).await?; + let restarted = policy_management(agent).await?; + replacement["ExpectedStoreToken"] = restarted["StoreToken"].clone(); + let expired = send_replacement(&agent.pipe_name, &replacement).await?; + ensure!( + expired.status == 422 && expired.json()?["Code"] == "ValidationFailed", + "pre-restart receipt remained valid in a new validator instance" + ); + ensure!( + restarted["Policy"] == current["Policy"] + && policy_management(agent).await?["StoreToken"] == restarted["StoreToken"], + "rejected requests changed the policy or exact store token" + ); + let replaced = replace_policy(agent, "ReplaceIdentity", restarted["StoreToken"].clone(), draft).await?; + ensure!( + replaced["Policy"]["Metadata"]["Id"] == "tests.replaced-identity" + && replaced["Policy"]["Metadata"]["Revision"] == 1 + && replaced["Policy"]["PolicyFormatVersion"] == "1.7.3", + "advisory findings did not permit the canonical identity replacement" + ); + wait_for_log(agent, "Policy change succeeded").await?; + wait_for_log(agent, "replace_identity").await?; + wait_for_log(agent, "invalid_receipt").await?; + Ok(replaced) +} + +async fn interrupted_malformed_repair(agent_path: &Path) -> anyhow::Result<()> { + let original = b"malformed-policy-secret-marker"; + for marker_staging in [false, true] { + let data_dir = create_data_dir()?; + let policy_path = data_dir.path().join("policy.json"); + std::fs::write(&policy_path, original)?; + secure_policy_path(&policy_path, false)?; + let prefix = ".policy.json.txn-11111111-2222-4333-8444-555555555555"; + let new_path = data_dir.path().join(format!("{prefix}.new")); + std::fs::write(&new_path, b"partial replacement")?; + secure_policy_path(&new_path, false)?; + let marker_path = data_dir.path().join(format!("{prefix}.marker.prepare")); + if marker_staging { + std::fs::write(&marker_path, br#"{"Version":"#)?; + secure_policy_path(&marker_path, false)?; + } + let mut agent = AgentHarness::start_with_path(agent_path, data_dir, unique_pipe_name(), policy_path).await?; + let invalid = policy_management(&agent).await?; + ensure!( + invalid["State"] == "Invalid" && invalid["WriteCapability"] == "Writable", + "interrupted Repair did not retain a repairable invalid original: {invalid}" + ); + ensure!( + std::fs::read(&agent.policy_path)? == original && !new_path.exists() && !marker_path.exists(), + "recovery changed the original or retained prepublication remnants" + ); + ensure!( + request(&agent.pipe_name, "GET", "/v1/policy").await?.status == 404, + "malformed policy became active" + ); + let repaired = replace_policy( + &agent, + "Repair", + invalid["StoreToken"].clone(), + policy_draft("tests.interrupted-repair", "Recovered"), + ) + .await?; + agent.restart(agent_path).await?; + ensure!( + policy_management(&agent).await?["Policy"] == repaired["Policy"], + "repaired policy did not survive restart" + ); + agent.stop().await?; + } + Ok(()) +} + +async fn wait_for_management(agent: &AgentHarness, predicate: impl Fn(&Value) -> bool) -> anyhow::Result { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let management = policy_management(agent).await?; + if predicate(&management) { + return Ok(management); + } + ensure!(Instant::now() < deadline, "timed out waiting for policy state"); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +async fn wait_for_log(agent: &AgentHarness, expected: &str) -> anyhow::Result<()> { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if agent.logs()?.contains(expected) { + return Ok(()); + } + ensure!(Instant::now() < deadline, "Agent log did not contain '{expected}'"); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + async fn unavailable_policy_and_method_restrictions(agent_path: &Path) -> anyhow::Result<()> { let agent = AgentHarness::start(agent_path, None).await?; @@ -598,6 +1452,7 @@ async fn complete_snapshots_across_reload(agent_path: &Path) -> anyhow::Result<( if policy == &full { break; } + ensure!(Instant::now() < deadline, "agent did not reload the policy"); tokio::task::yield_now().await; } @@ -613,3 +1468,46 @@ async fn complete_snapshots_across_reload(agent_path: &Path) -> anyhow::Result<( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn policy_fixtures_use_the_current_contract() { + for policy in [full_policy(), empty_policy(), policy_draft("tests.contract", "Test")] { + assert_eq!(policy["PolicyFormatVersion"], "1.0.0"); + } + } + + #[test] + fn standard_user_token_rejects_wrong_identity_and_administrators() { + for (actual_sid, administrator, integrity) in [ + ("S-1-5-21-1-2-3-1002", false, SECURITY_MANDATORY_MEDIUM_RID), + ("S-1-5-21-1-2-3-1001", true, SECURITY_MANDATORY_MEDIUM_RID), + ("S-1-5-21-1-2-3-1001", false, 0x3000), + ] { + assert!(validate_standard_user_token(actual_sid, "S-1-5-21-1-2-3-1001", administrator, integrity).is_err()); + } + } + + #[test] + fn standard_user_token_requires_medium_integrity() { + validate_standard_user_token( + "S-1-5-21-1-2-3-1001", + "S-1-5-21-1-2-3-1001", + false, + SECURITY_MANDATORY_MEDIUM_RID, + ) + .expect("matching standard-user SID at Medium integrity is valid"); + + let error = validate_standard_user_token( + "S-1-5-21-1-2-3-1001", + "S-1-5-21-1-2-3-1001", + false, + SECURITY_MANDATORY_LOW_RID, + ) + .expect_err("Low integrity must not satisfy the standard-user scenario"); + assert!(error.to_string().contains("requires Medium integrity")); + } +} diff --git a/crates/agent-sysevent-codes/Cargo.toml b/crates/agent-sysevent-codes/Cargo.toml new file mode 100644 index 000000000..beef0008e --- /dev/null +++ b/crates/agent-sysevent-codes/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "agent-sysevent-codes" +version = "0.0.0" +edition = "2024" +authors = ["Devolutions Inc. "] +license = "MIT OR Apache-2.0" +publish = false + +[lints] +workspace = true + +[dependencies] +sysevent.path = "../sysevent" diff --git a/crates/agent-sysevent-codes/src/lib.rs b/crates/agent-sysevent-codes/src/lib.rs new file mode 100644 index 000000000..05620c3b0 --- /dev/null +++ b/crates/agent-sysevent-codes/src/lib.rs @@ -0,0 +1,123 @@ +//! Devolutions Agent-specific Windows Event Log event definitions. + +use std::path::Path; + +use sysevent::{Entry, Severity}; + +pub const POLICY_WRITE_ATTEMPTED: u32 = 8000; +pub const POLICY_WRITE_DENIED: u32 = 8001; +pub const POLICY_CREATE_FAILED: u32 = 8002; +pub const POLICY_CREATE_SUCCEEDED: u32 = 8003; +pub const POLICY_CHANGE_FAILED: u32 = 8004; +pub const POLICY_CHANGE_SUCCEEDED: u32 = 8005; +pub const POLICY_EXTERNAL_CHANGE_APPLIED: u32 = 8010; +pub const POLICY_EXTERNAL_CHANGE_REJECTED: u32 = 8011; + +pub fn policy_write_attempted( + actor_sid: impl ToString, + actor_exe: impl ToString, + intent: impl ToString, + path: &Path, +) -> Entry { + Entry::new("Policy management write attempted") + .event_code(POLICY_WRITE_ATTEMPTED) + .severity(Severity::Info) + .field("actor_sid", actor_sid) + .field("actor_exe", actor_exe) + .field("intent", intent) + .field("path", path.display()) +} + +pub fn policy_write_denied( + actor_sid: impl ToString, + actor_exe: impl ToString, + intent: impl ToString, + path: &Path, + reason: impl ToString, +) -> Entry { + Entry::new("Policy management write denied") + .event_code(POLICY_WRITE_DENIED) + .severity(Severity::Warning) + .field("actor_sid", actor_sid) + .field("actor_exe", actor_exe) + .field("intent", intent) + .field("path", path.display()) + .field("reason", reason) +} + +#[expect( + clippy::too_many_arguments, + reason = "the shared builder keeps the Create and change failure events field-compatible" +)] +pub fn policy_write_failed( + event_code: u32, + message: &'static str, + actor_sid: impl ToString, + actor_exe: impl ToString, + intent: impl ToString, + path: impl AsRef, + operation: impl ToString, + outcome: impl ToString, + reason: impl ToString, +) -> Entry { + Entry::new(message) + .event_code(event_code) + .severity(Severity::Error) + .field("actor_sid", actor_sid) + .field("actor_exe", actor_exe) + .field("intent", intent) + .field("path", path.as_ref().display()) + .field("operation", operation) + .field("outcome", outcome) + .field("reason", reason) +} + +#[expect( + clippy::too_many_arguments, + reason = "the audit event records both policy identities and the operation outcome" +)] +pub fn policy_write_succeeded( + event_code: u32, + message: &'static str, + actor_sid: impl ToString, + actor_exe: impl ToString, + path: impl AsRef, + old_id: impl ToString, + old_revision: impl ToString, + new_id: impl ToString, + new_revision: u32, + intent: impl ToString, + operation: impl ToString, + outcome: impl ToString, +) -> Entry { + Entry::new(message) + .event_code(event_code) + .severity(Severity::Info) + .field("actor_sid", actor_sid) + .field("actor_exe", actor_exe) + .field("path", path.as_ref().display()) + .field("old_id", old_id) + .field("old_revision", old_revision) + .field("new_id", new_id) + .field("new_revision", new_revision) + .field("intent", intent) + .field("operation", operation) + .field("outcome", outcome) +} + +pub fn policy_external_change_applied(path: impl AsRef, new_id: impl ToString, new_revision: u32) -> Entry { + Entry::new("External policy change applied") + .event_code(POLICY_EXTERNAL_CHANGE_APPLIED) + .severity(Severity::Notice) + .field("path", path.as_ref().display()) + .field("new_id", new_id) + .field("new_revision", new_revision) +} + +pub fn policy_external_change_rejected(path: impl AsRef, reason: impl ToString) -> Entry { + Entry::new("External policy change rejected") + .event_code(POLICY_EXTERNAL_CHANGE_REJECTED) + .severity(Severity::Warning) + .field("path", path.as_ref().display()) + .field("reason", reason) +} diff --git a/crates/agent-sysevent-codes/tests/message_catalog_parity.rs b/crates/agent-sysevent-codes/tests/message_catalog_parity.rs new file mode 100644 index 000000000..f838bfda0 --- /dev/null +++ b/crates/agent-sysevent-codes/tests/message_catalog_parity.rs @@ -0,0 +1,48 @@ +use std::path::Path; + +const EVENTS: &[(u32, usize)] = &[ + (agent_sysevent_codes::POLICY_WRITE_ATTEMPTED, 5), + (agent_sysevent_codes::POLICY_WRITE_DENIED, 6), + (agent_sysevent_codes::POLICY_CREATE_FAILED, 8), + (agent_sysevent_codes::POLICY_CREATE_SUCCEEDED, 11), + (agent_sysevent_codes::POLICY_CHANGE_FAILED, 8), + (agent_sysevent_codes::POLICY_CHANGE_SUCCEEDED, 11), + (agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED, 4), + (agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_REJECTED, 3), +]; + +#[test] +fn policy_events_match_the_agent_catalog() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../devolutions-agent/devolutions-agent.mc"); + let catalog = std::fs::read_to_string(&path).unwrap_or_else(|error| panic!("read {}: {error}", path.display())); + + for &(code, insertion_count) in EVENTS { + let marker = format!("MessageId={code}"); + let start = catalog + .find(&marker) + .unwrap_or_else(|| panic!("Agent catalog omits {marker}")); + let block = &catalog[start + ..catalog[start..] + .find("\nMessageId=") + .map_or(catalog.len(), |end| start + end)]; + let messages: Vec<_> = block + .lines() + .enumerate() + .filter(|(_, line)| line.starts_with("Language=")) + .map(|(index, _)| block.lines().nth(index + 1).unwrap_or_default()) + .collect(); + assert_eq!(messages.len(), 3, "Agent catalog {marker}"); + for message in messages { + for insertion in 1..=insertion_count { + assert!( + message.contains(&format!("%{insertion}")), + "Agent catalog {marker} omits %{insertion}" + ); + } + assert!( + !message.contains(&format!("%{}", insertion_count + 1)), + "Agent catalog {marker} has an unexpected insertion" + ); + } + } +} diff --git a/crates/now-package-broker/Cargo.toml b/crates/now-package-broker/Cargo.toml index 662cbc510..5b407647b 100644 --- a/crates/now-package-broker/Cargo.toml +++ b/crates/now-package-broker/Cargo.toml @@ -34,18 +34,22 @@ notify = { version = "7", default-features = false } http-body-util = "0.1" mime = "0.3" now-policy = "=0.5.0" -now-policy-api = "=0.6.0" -now-policy-server-template = "=0.6.0" +now-policy-api = "=0.7.0" +now-policy-server-template = "=0.7.0" parking_lot = "0.12" regex = "1" semver = "1" serde = "1" serde_json = "1" sha2 = "0.10" +sysevent = { path = "../sysevent" } +agent-sysevent-codes = { path = "../agent-sysevent-codes" } +sysevent-winevent = { path = "../sysevent-winevent" } tokio = { version = "1.52", features = ["net", "io-util", "rt", "macros", "parking_lot", "fs", "sync", "time"] } tokio-util = "0.7" tower-service = "0.3" tracing = "0.1" +unicode-normalization = "0.1" uuid = { version = "1.23", features = ["v4"] } widestring = "1.2" win-api-wrappers = { path = "../win-api-wrappers" } diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs new file mode 100644 index 000000000..598971c4e --- /dev/null +++ b/crates/now-package-broker/src/audit.rs @@ -0,0 +1,597 @@ +//! Structured audit events for policy management writes and external policy changes. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +#[cfg(all(not(test), not(debug_assertions)))] +use std::sync::atomic::AtomicU64; +use std::sync::atomic::{AtomicBool, Ordering}; + +use agent_sysevent_codes as policy_events; +use now_policy_api::{PolicyManagementState, PolicyReplacementOperation}; +use sysevent::Entry; +#[cfg(not(test))] +use sysevent::Severity; +#[cfg(all(not(test), not(debug_assertions)))] +use sysevent::SystemEventSink; +use win_api_wrappers::identity::sid::Sid; + +const INTENT: &str = "PUT /v1/policy"; +const MAX_SID_BYTES: usize = 256; +const MAX_PATH_BYTES: usize = 1024; +const MAX_POLICY_ID_BYTES: usize = 256; +#[cfg(all(not(test), not(debug_assertions)))] +const EVENT_LOG_QUEUE_CAPACITY: usize = 256; + +static RECORDER: std::sync::LazyLock> = std::sync::LazyLock::new(default_recorder); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DenialReason { + AuthenticationFailed, + AdministratorRequired, + RequestRejected, +} + +impl DenialReason { + const fn as_str(self) -> &'static str { + match self { + Self::AuthenticationFailed => "authentication_failed", + Self::AdministratorRequired => "administrator_required", + Self::RequestRejected => "request_rejected", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FailureReason { + MonitoringUnavailable, + StaleStoreToken, + PathNotWritable, + InvalidPolicy, + InvalidReceipt, + RevisionConflict, + DraftCommitFailed, + SerializationFailed, + PersistenceFailed, + ConditionalPublicationFailed, + ActivationFailed, +} + +impl FailureReason { + const fn as_str(self) -> &'static str { + match self { + Self::MonitoringUnavailable => "monitoring_unavailable", + Self::StaleStoreToken => "stale_store_token", + Self::PathNotWritable => "path_not_writable", + Self::InvalidPolicy => "invalid_policy", + Self::InvalidReceipt => "invalid_receipt", + Self::RevisionConflict => "revision_conflict", + Self::DraftCommitFailed => "draft_commit_failed", + Self::SerializationFailed => "serialization_failed", + Self::PersistenceFailed => "persistence_failed", + Self::ConditionalPublicationFailed => "conditional_publication_failed", + Self::ActivationFailed => "activation_failed", + } + } +} + +trait AuditRecorder: Send + Sync { + fn record(&self, entry: Entry); +} + +fn default_recorder() -> Arc { + #[cfg(test)] + { + Arc::new(tests::TestRecorder) + } + #[cfg(all(not(test), debug_assertions))] + { + Arc::new(TracingRecorder) + } + #[cfg(all(not(test), not(debug_assertions)))] + { + match SystemRecorder::new() { + Ok(recorder) => Arc::new(recorder), + Err(error) => { + tracing::error!(%error, "Failed to start the Windows Event Log policy audit worker"); + Arc::new(TracingRecorder) + } + } + } +} + +#[cfg(not(test))] +struct TracingRecorder; + +#[cfg(not(test))] +impl AuditRecorder for TracingRecorder { + fn record(&self, entry: Entry) { + trace_entry(&entry); + } +} + +#[cfg(all(not(test), not(debug_assertions)))] +struct SystemRecorder { + sender: std::sync::mpsc::SyncSender, + dropped: AtomicU64, +} + +#[cfg(all(not(test), not(debug_assertions)))] +impl SystemRecorder { + fn new() -> std::io::Result { + let (sender, receiver) = std::sync::mpsc::sync_channel(EVENT_LOG_QUEUE_CAPACITY); + std::thread::Builder::new() + .name("policy-audit-event-log".to_owned()) + .spawn(move || event_log_worker(&receiver)) + .map(|_| Self { + sender, + dropped: AtomicU64::new(0), + }) + } +} + +#[cfg(all(not(test), not(debug_assertions)))] +impl AuditRecorder for SystemRecorder { + fn record(&self, entry: sysevent::Entry) { + trace_entry(&entry); + if let Err(error) = self.sender.try_send(entry) { + let dropped = self.dropped.fetch_add(1, Ordering::Relaxed) + 1; + if dropped.is_power_of_two() { + tracing::warn!( + dropped, + error = %match error { + std::sync::mpsc::TrySendError::Full(_) => "queue_full", + std::sync::mpsc::TrySendError::Disconnected(_) => "worker_disconnected", + }, + "Dropped policy audit Windows Event Log entries" + ); + } + } + } +} + +#[cfg(not(test))] +fn trace_entry(entry: &Entry) { + let code = entry.event_code; + let message = &entry.message; + let fields = &entry.fields; + match entry.severity { + Severity::Critical | Severity::Error => tracing::error!(?code, %message, ?fields, "Policy audit event"), + Severity::Warning => tracing::warn!(?code, %message, ?fields, "Policy audit event"), + Severity::Notice | Severity::Info | Severity::Debug => { + tracing::info!(?code, %message, ?fields, "Policy audit event"); + } + } +} + +#[cfg(all(not(test), not(debug_assertions)))] +fn event_log_worker(receiver: &std::sync::mpsc::Receiver) { + let sink: Arc = match sysevent_winevent::WinEvent::new("Devolutions Agent") { + Ok(event_log) => Arc::new(event_log), + Err(error) => { + tracing::error!(%error, "Failed to initialize the Windows Event Log policy audit sink"); + Arc::new(sysevent::NoopSink) + } + }; + for entry in receiver { + if let Err(error) = sink.emit(entry) { + tracing::warn!(%error, "Failed to emit policy audit event to the Windows Event Log"); + } + } +} + +struct WriteAuditState { + actor_sid: String, + actor_exe: String, + path: PathBuf, + terminal_recorded: AtomicBool, + recorder: Arc, +} + +impl Drop for WriteAuditState { + fn drop(&mut self) { + if !self.terminal_recorded.swap(true, Ordering::AcqRel) { + self.record(policy_events::policy_write_denied( + &self.actor_sid, + &self.actor_exe, + INTENT, + &self.path, + DenialReason::RequestRejected.as_str(), + )); + } + } +} + +#[derive(Clone)] +pub(crate) struct WriteAudit(Arc); + +impl WriteAudit { + pub(crate) fn begin(actor_sid: &Sid, actor_exe: &Path, path: &Path) -> Self { + Self::begin_with_recorder(actor_sid, actor_exe, path, Arc::clone(&RECORDER)) + } + + fn begin_with_recorder(actor_sid: &Sid, actor_exe: &Path, path: &Path, recorder: Arc) -> Self { + let state = Arc::new(WriteAuditState { + actor_sid: bounded(actor_sid.to_string(), MAX_SID_BYTES), + actor_exe: bounded(actor_exe.display().to_string(), MAX_PATH_BYTES), + path: bounded_path(path), + terminal_recorded: AtomicBool::new(false), + recorder, + }); + state.record(policy_events::policy_write_attempted( + &state.actor_sid, + &state.actor_exe, + INTENT, + &state.path, + )); + Self(state) + } + + pub(crate) fn denied(&self, reason: DenialReason) { + self.finish(|state| { + policy_events::policy_write_denied(&state.actor_sid, &state.actor_exe, INTENT, &state.path, reason.as_str()) + }); + } + + pub(crate) fn failed(&self, operation: PolicyReplacementOperation, reason: FailureReason) { + self.failed_at(operation, &self.0.path, reason); + } + + pub(crate) fn failed_at(&self, operation: PolicyReplacementOperation, path: &Path, reason: FailureReason) { + let path = bounded_path(path); + let operation_name = operation_name(operation); + let outcome = if reason == FailureReason::StaleStoreToken { + "stale_conflict" + } else { + "failed" + }; + self.finish(|state| { + if operation == PolicyReplacementOperation::Create { + policy_events::policy_write_failed( + policy_events::POLICY_CREATE_FAILED, + "Policy creation failed", + &state.actor_sid, + &state.actor_exe, + INTENT, + path, + operation_name, + outcome, + reason.as_str(), + ) + } else { + policy_events::policy_write_failed( + policy_events::POLICY_CHANGE_FAILED, + "Policy change failed", + &state.actor_sid, + &state.actor_exe, + INTENT, + path, + operation_name, + outcome, + reason.as_str(), + ) + } + }); + } + + #[expect( + clippy::too_many_arguments, + reason = "the terminal event records operation and both policy identities" + )] + pub(crate) fn succeeded_at( + &self, + path: &Path, + old_id: Option<&str>, + old_revision: Option, + new_id: &str, + new_revision: u32, + operation: PolicyReplacementOperation, + confirmed_overwrite: bool, + ) { + let path = bounded_path(path); + let old_id = bounded(old_id.unwrap_or("").to_owned(), MAX_POLICY_ID_BYTES); + let old_revision = old_revision.map_or_else(|| "none".to_owned(), |revision| revision.to_string()); + let new_id = bounded(new_id.to_owned(), MAX_POLICY_ID_BYTES); + let operation_name = operation_name(operation); + let outcome = if confirmed_overwrite { + "confirmed_overwrite" + } else { + "applied" + }; + self.finish(|state| { + if operation == PolicyReplacementOperation::Create { + policy_events::policy_write_succeeded( + policy_events::POLICY_CREATE_SUCCEEDED, + "Policy creation succeeded", + &state.actor_sid, + &state.actor_exe, + path, + old_id, + old_revision, + new_id, + new_revision, + INTENT, + operation_name, + outcome, + ) + } else { + policy_events::policy_write_succeeded( + policy_events::POLICY_CHANGE_SUCCEEDED, + "Policy change succeeded", + &state.actor_sid, + &state.actor_exe, + path, + old_id, + old_revision, + new_id, + new_revision, + INTENT, + operation_name, + outcome, + ) + } + }); + } + + fn finish(&self, entry: impl FnOnce(&WriteAuditState) -> Entry) { + if self + .0 + .terminal_recorded + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + self.0.record(entry(&self.0)); + } + } +} + +impl WriteAuditState { + fn record(&self, entry: Entry) { + self.recorder.record(entry); + } +} + +pub(crate) fn external_change_applied(path: &Path, new_id: &str, new_revision: u32) { + RECORDER.record(policy_events::policy_external_change_applied( + bounded_path(path), + bounded(new_id.to_owned(), MAX_POLICY_ID_BYTES), + new_revision, + )); +} + +pub(crate) fn external_change_rejected(path: &Path, state: PolicyManagementState) { + let reason = match state { + PolicyManagementState::Active => "active", + PolicyManagementState::Missing => "missing", + PolicyManagementState::Invalid => "invalid", + }; + RECORDER.record(policy_events::policy_external_change_rejected( + bounded_path(path), + reason, + )); +} + +fn bounded(mut value: String, max_bytes: usize) -> String { + value = value + .chars() + .map(|character| if is_audit_control(character) { ' ' } else { character }) + .collect(); + if value.len() <= max_bytes { + return value; + } + const SUFFIX: &str = "..."; + let mut end = max_bytes - SUFFIX.len(); + while !value.is_char_boundary(end) { + end -= 1; + } + value.truncate(end); + value.push_str(SUFFIX); + value +} + +fn is_audit_control(character: char) -> bool { + character.is_control() + || matches!( + character, + '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{2028}' | '\u{2029}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' + ) +} + +fn bounded_path(path: &Path) -> PathBuf { + PathBuf::from(bounded(path.display().to_string(), MAX_PATH_BYTES)) +} + +const fn operation_name(operation: PolicyReplacementOperation) -> &'static str { + match operation { + PolicyReplacementOperation::Create => "create", + PolicyReplacementOperation::Update => "update", + PolicyReplacementOperation::Repair => "repair", + PolicyReplacementOperation::ReplaceIdentity => "replace_identity", + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + std::thread_local! { + static EVENTS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; + } + + pub(crate) struct TestRecorder; + + impl AuditRecorder for TestRecorder { + fn record(&self, entry: Entry) { + EVENTS.with(|events| events.borrow_mut().push(entry)); + } + } + + pub(crate) fn take_events() -> Vec { + EVENTS.with(|events| std::mem::take(&mut *events.borrow_mut())) + } + + #[derive(Default)] + pub(crate) struct Recorder(parking_lot::Mutex>); + + impl Recorder { + pub(crate) fn events(&self) -> Vec { + self.0.lock().clone() + } + } + + impl AuditRecorder for Recorder { + fn record(&self, entry: Entry) { + self.0.lock().push(entry); + } + } + + pub(crate) fn begin(actor_sid: &Sid, actor_exe: &Path, path: &Path) -> (WriteAudit, Arc) { + let recorder = Arc::new(Recorder::default()); + let audit = WriteAudit::begin_with_recorder( + actor_sid, + actor_exe, + path, + Arc::clone(&recorder) as Arc, + ); + (audit, recorder) + } + + pub(crate) fn noop() -> WriteAudit { + let sid = Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).expect("SYSTEM SID"); + WriteAudit::begin_with_recorder( + &sid, + Path::new(r"C:\test-client.exe"), + Path::new(r"C:\policy.json"), + Arc::new(NoopRecorder), + ) + } + + struct NoopRecorder; + + impl AuditRecorder for NoopRecorder { + fn record(&self, _: Entry) {} + } + + fn test_audit() -> (WriteAudit, Arc) { + let sid = Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).expect("SYSTEM SID"); + begin(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json")) + } + + #[test] + fn attempt_precedes_denial_and_only_one_terminal_event_is_recorded() { + let (audit, recorder) = test_audit(); + audit.denied(DenialReason::AuthenticationFailed); + audit.failed(PolicyReplacementOperation::Update, FailureReason::InvalidPolicy); + assert_eq!( + recorder + .events() + .iter() + .map(|entry| entry.event_code) + .collect::>(), + [ + Some(policy_events::POLICY_WRITE_ATTEMPTED), + Some(policy_events::POLICY_WRITE_DENIED) + ] + ); + } + + #[test] + fn abandoned_clones_record_one_terminal_denial() { + let (audit, recorder) = test_audit(); + let retained = audit.clone(); + drop(audit); + assert_eq!(recorder.events().len(), 1); + drop(retained); + let events = recorder.events(); + assert_eq!(events.len(), 2); + assert_eq!(events[1].event_code, Some(policy_events::POLICY_WRITE_DENIED)); + assert!( + events[1] + .fields + .iter() + .any(|(name, value)| name == "reason" && value == "request_rejected") + ); + } + + #[test] + fn audit_text_removes_control_characters_before_truncation() { + let value = format!( + "injected\r\n\t\0\u{061c}\u{200e}\u{200f}\u{2028}\u{2029}\u{202a}\u{202b}\u{202c}\u{202d}\u{202e}\u{2066}\u{2067}\u{2068}\u{2069}{}", + "é".repeat(MAX_POLICY_ID_BYTES) + ); + let bounded = bounded(value, MAX_POLICY_ID_BYTES); + assert!(bounded.len() <= MAX_POLICY_ID_BYTES); + assert!(bounded.ends_with("...")); + assert!(!bounded.chars().any(is_audit_control)); + } + + #[test] + fn audit_values_are_bounded_and_fields_are_allowlisted() { + let sid = Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).expect("SYSTEM SID"); + let long = "é".repeat(MAX_PATH_BYTES); + let (audit, recorder) = begin(&sid, Path::new(&long), Path::new(&long)); + audit.succeeded_at( + Path::new(&long), + Some(&long), + Some(1), + &long, + 2, + PolicyReplacementOperation::Update, + false, + ); + + let events = recorder.events(); + let entry = &events[1]; + assert!(entry.fields.iter().all(|(name, value)| { + matches!( + name.as_str(), + "actor_sid" + | "actor_exe" + | "intent" + | "path" + | "old_id" + | "old_revision" + | "new_id" + | "new_revision" + | "operation" + | "outcome" + ) && value.len() <= MAX_PATH_BYTES + })); + for forbidden in ["body", "draft", "policy", "receipt", "store_token"] { + assert!(!entry.fields.iter().any(|(name, _)| name == forbidden)); + } + } + + #[test] + fn terminal_event_codes_follow_the_replacement_operation() { + for (operation, failure_code, success_code) in [ + ( + PolicyReplacementOperation::Create, + policy_events::POLICY_CREATE_FAILED, + policy_events::POLICY_CREATE_SUCCEEDED, + ), + ( + PolicyReplacementOperation::Update, + policy_events::POLICY_CHANGE_FAILED, + policy_events::POLICY_CHANGE_SUCCEEDED, + ), + ( + PolicyReplacementOperation::Repair, + policy_events::POLICY_CHANGE_FAILED, + policy_events::POLICY_CHANGE_SUCCEEDED, + ), + ( + PolicyReplacementOperation::ReplaceIdentity, + policy_events::POLICY_CHANGE_FAILED, + policy_events::POLICY_CHANGE_SUCCEEDED, + ), + ] { + let (failed, failed_recorder) = test_audit(); + failed.failed(operation, FailureReason::StaleStoreToken); + assert_eq!(failed_recorder.events()[1].event_code, Some(failure_code)); + + let (succeeded, succeeded_recorder) = test_audit(); + succeeded.succeeded_at(Path::new(r"C:\policy.json"), None, None, "new", 1, operation, true); + assert_eq!(succeeded_recorder.events()[1].event_code, Some(success_code)); + } + } +} diff --git a/crates/now-package-broker/src/auth.rs b/crates/now-package-broker/src/auth.rs index d891f6351..f293a0306 100644 --- a/crates/now-package-broker/src/auth.rs +++ b/crates/now-package-broker/src/auth.rs @@ -32,6 +32,7 @@ use windows::Win32::System::Threading::{ use crate::policy_security::RetainedExecutableSecurity; const PROCESS_SYNCHRONIZE: PROCESS_ACCESS_RIGHTS = PROCESS_ACCESS_RIGHTS(0x0010_0000); +const POLICY_CONSENT_HELPER_NAME: &str = "DevolutionsAgentPolicyConsent.exe"; const PROCESS_IDENTITY_ACCESS: PROCESS_ACCESS_RIGHTS = PROCESS_ACCESS_RIGHTS( PROCESS_QUERY_INFORMATION.0 | PROCESS_QUERY_LIMITED_INFORMATION.0 | PROCESS_VM_READ.0 | PROCESS_SYNCHRONIZE.0, ); @@ -222,6 +223,10 @@ impl PipeClient { &self.user_sid } + pub(crate) fn executable_path(&self) -> &Path { + &self.executable_path + } + pub(crate) fn is_elevated_administrator(&self) -> bool { self.is_elevated && self.is_administrator } @@ -285,6 +290,20 @@ impl PipeClient { Ok(()) } + pub(crate) fn validate_policy_write(&self, skip_signature_validation: bool) -> anyhow::Result<()> { + self.validate_connection(skip_signature_validation)?; + // Dev builds cannot enforce helper identity when signature validation is explicitly disabled. + if signature_validation_skipped(skip_signature_validation) { + return Ok(()); + } + let agent = std::env::current_exe().context("failed to query Agent executable path")?; + let executable_file = self + .executable_file + .as_deref() + .context("policy consent helper executable handle is not retained")?; + Self::validate_policy_consent_helper_path(&self.executable_path, executable_file, &agent) + } + fn validate_process_instance(&self) -> anyhow::Result<()> { let Some(process) = &self.process else { return Ok(()); @@ -300,6 +319,29 @@ impl PipeClient { ) } + fn validate_policy_consent_helper_path(client: &Path, client_file: &File, agent: &Path) -> anyhow::Result<()> { + if !client + .file_name() + .is_some_and(|name| name.eq_ignore_ascii_case(POLICY_CONSENT_HELPER_NAME)) + { + bail!("policy replacement requires the Agent policy consent helper"); + } + let expected = agent + .parent() + .context("Agent executable has no installation directory")? + .join(POLICY_CONSENT_HELPER_NAME); + if !crate::policy_security::windows_paths_equal(client, &expected) { + bail!("policy consent helper is not the installed Agent helper path"); + } + let expected_id = file_id(&expected).context("failed to query installed policy consent helper identity")?; + let retained_id = + file_id_from_handle(client_file).context("failed to query retained policy consent helper identity")?; + if !same_file(&expected_id, &retained_id) { + bail!("policy consent helper does not match the installed helper"); + } + Ok(()) + } + /// Validate that the request's `effective_user` denotes the authenticated pipe client user. /// /// The name is resolved to a SID and compared against the SID captured at connect, @@ -535,6 +577,51 @@ mod tests { .expect_err("a recycled PID with a different creation time must be rejected"); } + #[test] + fn policy_consent_helper_requires_exact_agent_sibling_path() { + let current_executable = std::env::current_exe().expect("current executable"); + let current_file = open_executable_file(¤t_executable).expect("open current executable"); + let agent = Path::new(r"C:\Program Files\Devolutions\Agent\DevolutionsAgent.exe"); + assert!( + PipeClient::validate_policy_consent_helper_path( + Path::new(r"C:\Program Files\Devolutions\Agent\DevolutionsAgentPolicyConsent.exe"), + ¤t_file, + agent, + ) + .is_err(), + "path text alone must not authorize a different retained image" + ); + assert!( + PipeClient::validate_policy_consent_helper_path( + Path::new(r"C:\Users\Alice\DevolutionsAgentPolicyConsent.exe"), + ¤t_file, + agent, + ) + .is_err() + ); + assert!( + PipeClient::validate_policy_consent_helper_path( + Path::new(r"C:\Users\Alice\UniGetUI.exe"), + ¤t_file, + agent, + ) + .is_err() + ); + } + + #[test] + fn policy_consent_helper_accepts_exact_retained_sibling() { + let temp = tempfile::tempdir().expect("temp directory"); + let agent = temp.path().join("DevolutionsAgent.exe"); + let helper = temp.path().join(POLICY_CONSENT_HELPER_NAME); + std::fs::write(&agent, b"agent path anchor").expect("write Agent path anchor"); + std::fs::copy(std::env::current_exe().expect("current executable"), &helper).expect("copy helper fixture"); + let retained = open_executable_file(&helper).expect("retain helper fixture"); + + PipeClient::validate_policy_consent_helper_path(&helper, &retained, &agent) + .expect("exact retained sibling must be accepted"); + } + #[test] fn exited_process_cannot_supply_executable_identity() { let mut child = std::process::Command::new("powershell.exe") diff --git a/crates/now-package-broker/src/evaluator/matching.rs b/crates/now-package-broker/src/evaluator/matching.rs index 4ee5301a9..3bd0dbf80 100644 --- a/crates/now-package-broker/src/evaluator/matching.rs +++ b/crates/now-package-broker/src/evaluator/matching.rs @@ -9,7 +9,7 @@ use now_policy_api::PackageRequest; use super::RequestFlags; use super::constraints::constraints_pass; -use super::wildcard::wildcard_any; +use super::wildcard::{literal_case_insensitive_match, wildcard_any}; pub(super) fn rule_matches( rule: &PolicyRule, @@ -21,7 +21,7 @@ pub(super) fn rule_matches( operations_match(request.operation, &m.operations) && managers_match(request.manager, &m.managers) - && source_names_match(&request.source.name, &m.source_names) + && source_names_match(request.manager, &request.source.name, &m.source_names) && package_identifiers_match(&request.package.id, &m.package_identifiers) && versions_match(effective_version, &m.version) && scopes_match(request.options.scope, &m.scopes) @@ -129,8 +129,18 @@ fn elevation_match(elevation: now_policy_api::Elevation, allowed: &BTreeSet) -> bool { - allowed.is_empty() || allowed.iter().any(|source| source.as_ref().eq_ignore_ascii_case(value)) +fn source_names_match( + manager: now_policy_api::ManagerName, + value: &str, + allowed: &BTreeSet, +) -> bool { + allowed.is_empty() + || allowed.iter().any(|source| match manager { + now_policy_api::ManagerName::PowerShell | now_policy_api::ManagerName::PowerShell7 => { + literal_case_insensitive_match(value, source.as_ref()) + } + _ => source.as_ref().eq_ignore_ascii_case(value), + }) } fn package_identifiers_match( @@ -262,6 +272,20 @@ mod tests { })); } + #[test] + fn non_powershell_source_names_remain_canonically_distinct() { + let mut request = request(); + request.manager = api::ManagerName::Scoop; + request.source.name = "CO\u{0308}RP".to_owned(); + let flags = RequestFlags::from_request(&request); + let rule = rule(PolicyMatch { + source_names: BTreeSet::from([now_policy::SourceName::parse("CÖRP").expect("valid source")]), + ..Default::default() + }); + + assert!(!rule_matches(&rule, &request, &flags, "1.2.3")); + } + #[test] fn absent_scope_or_architecture_in_request_fails_when_rule_restricts_them() { let mut request = request(); diff --git a/crates/now-package-broker/src/evaluator/mod.rs b/crates/now-package-broker/src/evaluator/mod.rs index d0376caf8..729c8abc6 100644 --- a/crates/now-package-broker/src/evaluator/mod.rs +++ b/crates/now-package-broker/src/evaluator/mod.rs @@ -113,6 +113,12 @@ pub fn evaluate(policy: &PolicyDocument, request: &PackageRequest) -> PolicyDeci } } +/// Whether a source spelling has a stable identity across package-manager lookup and +/// policy evaluation. +pub(crate) fn source_name_is_unambiguous(source_name: &str) -> bool { + source_name == source_name.trim() && !wildcard::has_default_ignorable_code_point(source_name) +} + pub(crate) fn effective_execution_elevation(request: &PackageRequest) -> Elevation { if request.options.scope == Some(Scope::Machine) || request.client.requested_elevation == Elevation::Elevated { Elevation::Elevated diff --git a/crates/now-package-broker/src/evaluator/tests.rs b/crates/now-package-broker/src/evaluator/tests.rs index 8b6776a08..3e845a5e3 100644 --- a/crates/now-package-broker/src/evaluator/tests.rs +++ b/crates/now-package-broker/src/evaluator/tests.rs @@ -5,11 +5,11 @@ use std::collections::BTreeSet; use chrono::Utc; use now_policy::{ Decision, PackageIdentifier, PackageIdentifierCondition, PolicyDocument, PolicyEnforcement, PolicyFormatVersion, - PolicyMatch, PolicyMetadata, PolicyRule, ResourceId, + PolicyMatch, PolicyMetadata, PolicyRule, ResourceId, SourceName, }; use now_policy_api::{self as api, PackageRequest}; -use super::evaluate; +use super::{evaluate, source_name_is_unambiguous}; fn make_policy(default_decision: Decision, rules: Vec) -> PolicyDocument { PolicyDocument { @@ -128,6 +128,52 @@ fn deny_unmatched_package() { assert_eq!(result.rule_id, ""); } +#[test] +fn unicode_case_equivalent_source_deny_outranks_allow() { + let policy = make_policy( + Decision::Deny, + vec![ + PolicyRule { + id: ResourceId::from("allow-any"), + enabled: true, + priority: 100, + decision: Decision::Allow, + reason: None, + match_criteria: PolicyMatch::default(), + constraints: None, + }, + PolicyRule { + id: ResourceId::from("deny-corp"), + enabled: true, + priority: 100, + decision: Decision::Deny, + reason: None, + match_criteria: PolicyMatch { + source_names: BTreeSet::from([SourceName::parse("CÖRP").expect("valid source")]), + ..Default::default() + }, + constraints: None, + }, + ], + ); + let mut request = make_request(api::Operation::Install, "Example.Package"); + request.manager = api::ManagerName::PowerShell; + request.source.name = "cörp".to_owned(); + + let result = evaluate(&policy, &request); + + assert_eq!(result.decision, Decision::Deny); + assert_eq!(result.rule_id, "deny-corp"); +} + +#[test] +fn default_ignorable_source_spelling_is_rejected_before_evaluation() { + assert!(!source_name_is_unambiguous("PS\u{00AD}Gallery")); + assert!(!source_name_is_unambiguous("PSGallery ")); + assert!(!source_name_is_unambiguous(" PSGallery")); + assert!(source_name_is_unambiguous("PSGallery")); +} + #[test] fn disabled_rules_are_ignored() { let policy = make_policy( diff --git a/crates/now-package-broker/src/evaluator/wildcard.rs b/crates/now-package-broker/src/evaluator/wildcard.rs index 68e99df85..fc586e9a8 100644 --- a/crates/now-package-broker/src/evaluator/wildcard.rs +++ b/crates/now-package-broker/src/evaluator/wildcard.rs @@ -1,6 +1,13 @@ //! Case-insensitive wildcard matching helpers. use std::collections::BTreeSet; +use std::sync::LazyLock; + +use unicode_normalization::UnicodeNormalization as _; +use windows::Win32::Globalization::{CSTR_EQUAL, CompareStringOrdinal}; + +static DEFAULT_IGNORABLE_CODE_POINT: LazyLock = + LazyLock::new(|| regex::Regex::new(r"\p{Default_Ignorable_Code_Point}").expect("valid Unicode property regex")); pub(super) fn wildcard_any>(value: &str, patterns: &BTreeSet) -> bool { patterns.is_empty() || patterns.iter().any(|pattern| wildcard_match(value, pattern.as_ref())) @@ -10,6 +17,27 @@ pub(super) fn wildcard_any_vec>(value: &str, patterns: &[S]) -> bo patterns.iter().any(|pattern| wildcard_match(value, pattern.as_ref())) } +/// Match an exact source name using the PowerShell repository identity semantics. +/// +/// PowerShell resolves repository names after canonical Unicode normalization with +/// ordinal case-insensitive comparison. +/// Source names are literals, so this deliberately does not apply wildcard semantics. +pub(super) fn literal_case_insensitive_match(value: &str, expected: &str) -> bool { + let value: Vec = value.nfc().collect::().encode_utf16().collect(); + let expected: Vec = expected.nfc().collect::().encode_utf16().collect(); + + // SAFETY: The binding marshals both valid UTF-8 strings as bounded UTF-16. + unsafe { CompareStringOrdinal(&value, &expected, true) == CSTR_EQUAL } +} + +/// Default-ignorable characters are rejected before matching and command building. +/// +/// PowerShell repository lookup ignores them, while an opaque package request would +/// otherwise preserve them for `-Repository` and make policy identity ambiguous. +pub(super) fn has_default_ignorable_code_point(value: &str) -> bool { + DEFAULT_IGNORABLE_CODE_POINT.is_match(value) +} + fn wildcard_match(value: &str, pattern: &str) -> bool { // Convert glob pattern to regex: escape everything except *, which becomes .* let regex_pattern = format!("^{}$", regex::escape(pattern).replace(r"\*", ".*")); @@ -47,4 +75,17 @@ mod tests { assert!(wildcard_any("Contoso.Tools+", &patterns)); assert!(!wildcard_any("Contoso.Toolss", &patterns)); } + + #[test] + fn literal_match_uses_unicode_case_insensitive_semantics() { + assert!(literal_case_insensitive_match("CO\u{0308}RP", "CÖRP")); + assert!(!literal_case_insensitive_match("P\u{017F}Gallery", "PSGallery")); + assert!(!literal_case_insensitive_match("cörp", "CÖRP*")); + } + + #[test] + fn default_ignorable_source_characters_are_detected() { + assert!(has_default_ignorable_code_point("PS\u{00AD}Gallery")); + assert!(!has_default_ignorable_code_point("CÖRP")); + } } diff --git a/crates/now-package-broker/src/lib.rs b/crates/now-package-broker/src/lib.rs index e1542dda4..f8acf7e70 100644 --- a/crates/now-package-broker/src/lib.rs +++ b/crates/now-package-broker/src/lib.rs @@ -5,6 +5,8 @@ //! //! The broker is only functional on Windows; on other platforms this crate is empty. +#[cfg(windows)] +mod audit; #[cfg(windows)] mod auth; #[cfg(windows)] diff --git a/crates/now-package-broker/src/pipe.rs b/crates/now-package-broker/src/pipe.rs index 6a4c51b00..3ebe2c346 100644 --- a/crates/now-package-broker/src/pipe.rs +++ b/crates/now-package-broker/src/pipe.rs @@ -20,7 +20,7 @@ use windows::Win32::Security::Authorization::SET_ACCESS; use windows::Win32::Storage::FileSystem::{FILE_GENERIC_READ, FILE_GENERIC_WRITE}; use crate::auth::PipeClient; -use crate::server::{BrokerState, build_router_for_client, serve_connection}; +use crate::server::{BrokerState, build_router_for_client_with_policy_write_deadline, serve_connection}; /// Default pipe name for the package broker. pub const DEFAULT_PIPE_NAME: &str = r"\\.\pipe\Devolutions.Now.PackageBroker.v1"; @@ -36,12 +36,16 @@ const MAX_CONCURRENT_CONNECTIONS: usize = 16; /// Deadline for serving a single pipe connection, from accept to response completion. /// /// Each connection serves exactly one HTTP request (`keep_alive` is disabled) and all -/// endpoints respond without blocking on package operations (execution is asynchronous, -/// tracked via the operation tracker), so a healthy exchange completes well within this -/// deadline. Without it, idle clients holding their connection open without sending a -/// request would each pin a connection slot indefinitely and could exhaust the pool. +/// ordinary endpoints respond without blocking on package operations (execution is +/// asynchronous and tracked via the operation tracker). Without this deadline, idle +/// clients holding their connection open without sending a request would each pin a +/// connection slot indefinitely and could exhaust the pool. const CONNECTION_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30); +/// The bounded policy-replacement exchange lifetime used only after the exact installed +/// consent helper has passed write authorization. +const POLICY_CONSENT_CONNECTION_DEADLINE: std::time::Duration = std::time::Duration::from_secs(120); + /// Start the named pipe server and accept connections until shutdown. pub async fn run_pipe_server(state: Arc, shutdown: CancellationToken) -> anyhow::Result<()> { let pipe_name = state.pipe_name.clone(); @@ -72,40 +76,65 @@ pub async fn run_pipe_server(state: Arc, shutdown: CancellationToke match result { Ok(()) => { let state = Arc::clone(&state); + let connection_deadline = tokio::time::Instant::now() + CONNECTION_DEADLINE; tokio::spawn(async move { - let serve = async move { - // Keep blocking unauthenticated capture off the accept loop and - // retain the connection slot until the work actually completes. - let capture = spawn_bounded_capture(permit, move || { - let client = PipeClient::from_connected_pipe(&server); - (server, client) - }); - let (_permit, server, client) = match capture.await { - Ok((permit, (server, Ok(client)))) => (permit, server, client), - Ok((_permit, (_server, Err(error)))) => { - warn!(error = format!("{error:#}"), "Rejected named pipe client"); - return; - } - Err(error) => { - error!( - error = format!("{error:#}"), - "Named pipe client identity capture task failed" - ); - return; - } - }; - - info!("Client connected to named pipe"); - let router = build_router_for_client(state, client); - serve_connection(server, router).await; - info!("Client disconnected from named pipe"); + // Keep blocking unauthenticated capture off the accept loop and + // retain the connection slot until the work actually completes. + let capture = spawn_bounded_capture(permit, move || { + let client = PipeClient::from_connected_pipe(&server); + (server, client) + }); + let (_permit, server, client) = + match tokio::time::timeout_at(connection_deadline, capture).await { + Ok(Ok((permit, (server, Ok(client))))) => (permit, server, client), + Ok(Ok((_permit, (_server, Err(error))))) => { + warn!(error = format!("{error:#}"), "Rejected named pipe client"); + return; + } + Ok(Err(error)) => { + error!( + error = format!("{error:#}"), + "Named pipe client identity capture task failed" + ); + return; + } + Err(_) => { + warn!("Closed named pipe connection: client identity capture deadline exceeded"); + return; + } }; - // Enforce a deadline so idle or slow clients cannot pin - // a connection slot indefinitely. - if tokio::time::timeout(CONNECTION_DEADLINE, serve).await.is_err() { - warn!("Closed named pipe connection: deadline exceeded"); + info!("Client connected to named pipe"); + let (policy_write_deadline, mut policy_write_authorized) = tokio::sync::watch::channel(false); + let router = build_router_for_client_with_policy_write_deadline( + state, + client, + policy_write_deadline, + ); + let serve = serve_connection(server, router); + tokio::pin!(serve); + let mut policy_write_is_authorized = false; + let mut deadline = connection_deadline; + loop { + tokio::select! { + () = &mut serve => break, + () = tokio::time::sleep_until(deadline) => { + if policy_write_is_authorized { + warn!("Closed named pipe policy replacement: deadline exceeded"); + } else { + warn!("Closed named pipe connection: deadline exceeded"); + } + break; + } + result = policy_write_authorized.changed(), if !policy_write_is_authorized => { + if result.is_ok() && *policy_write_authorized.borrow_and_update() { + policy_write_is_authorized = true; + deadline = tokio::time::Instant::now() + POLICY_CONSENT_CONNECTION_DEADLINE; + } + } + } } + info!("Client disconnected from named pipe"); }); } Err(error) => { @@ -200,6 +229,12 @@ mod tests { use super::*; + #[test] + fn connection_deadlines_preserve_short_untrusted_and_long_authorized_bounds() { + assert_eq!(CONNECTION_DEADLINE, Duration::from_secs(30)); + assert_eq!(POLICY_CONSENT_CONNECTION_DEADLINE, Duration::from_secs(120)); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn timed_out_capture_keeps_its_permit_until_blocking_work_finishes() { let permits = Arc::new(Semaphore::new(1)); diff --git a/crates/now-package-broker/src/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs index 631025078..1785c9c67 100644 --- a/crates/now-package-broker/src/policy_store/mod.rs +++ b/crates/now-package-broker/src/policy_store/mod.rs @@ -9,9 +9,9 @@ use chrono::Utc; use now_policy::PolicyDocument; use now_policy_api::{ API_VERSION_STR, ErrorCode, ErrorResponse, ErrorResponseKind, InvalidPolicyDiagnostics, PolicyConfigurationSource, - PolicyManagementSnapshot, PolicyManagementState, PolicyReadOnlyReason, PolicyReplacementOperation, - PolicyReplacementRequest, PolicyStoreToken, PolicyValidationResult, PolicyWriteCapability, ServerContext, - Transport, + PolicyConflictHandling, PolicyManagementSnapshot, PolicyManagementState, PolicyReadOnlyReason, + PolicyReplacementOperation, PolicyReplacementRequest, PolicyStoreToken, PolicyValidationResult, + PolicyWriteCapability, ServerContext, Transport, }; mod receipt; @@ -255,7 +255,7 @@ impl PolicyStore { return self.management_snapshot(); } let (_, observation) = self.observe_storage(false); - let management = self.publish_observation(observation); + let management = self.publish_external_observation(observation); tracing::info!(?cause, state = ?management.state, "Reloaded package broker policy"); management } @@ -294,9 +294,15 @@ impl PolicyStore { self.publish_observation(observation); } - pub async fn replace(&self, request: PolicyReplacementRequest) -> Result { + pub(crate) async fn replace( + &self, + request: PolicyReplacementRequest, + audit: crate::audit::WriteAudit, + ) -> Result { + let operation = request.operation; let monitoring = self.writer.lock().await; if *monitoring != Monitoring::Available { + audit.failed(operation, crate::audit::FailureReason::MonitoringUnavailable); return Err(error_with_management( ErrorCode::BrokerPaused, "policy change monitoring is unavailable", @@ -310,7 +316,9 @@ impl PolicyStore { // Both conflict modes require this exact token. // ConfirmOverwrite records retry intent without retaining token history. if fresh_token != request.expected_store_token { - let management = self.publish_observation(observation); + let audit_path = observation.canonical_path.clone(); + let management = self.publish_external_observation(observation); + audit.failed_at(operation, &audit_path, crate::audit::FailureReason::StaleStoreToken); return Err(error_with_management( ErrorCode::StalePolicyStoreToken, "the configured policy changed after the supplied store token was observed", @@ -319,6 +327,11 @@ impl PolicyStore { } if observation.write_capability != PolicyWriteCapability::Writable { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::PathNotWritable, + ); let code = match observation.read_only_reason { Some(PolicyReadOnlyReason::UnsupportedFileSystem) => ErrorCode::UnsupportedPolicyFilesystem, Some(PolicyReadOnlyReason::UnsupportedFormat) => ErrorCode::UnsupportedPolicyFormat, @@ -329,6 +342,11 @@ impl PolicyStore { let validation = self.validate_draft(&request.draft); if !validation.is_valid { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::InvalidPolicy, + ); return Err(error_with_validation( ErrorCode::InvalidPolicy, "the submitted draft failed authoritative validation", @@ -345,35 +363,61 @@ impl PolicyStore { &validation.findings, &request.validation_receipt, ) { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::InvalidReceipt, + ); return Err(error_with_validation( ErrorCode::ValidationFailed, "the validation receipt does not match this draft", validation, )); } - if !validation.findings.is_empty() && !request.warnings_acknowledged { - return Err(error_with_validation( - ErrorCode::WarningConfirmationRequired, - "validation warnings must be explicitly acknowledged", - validation, - )); - } - - let revision = plan_revision( + let revision = match plan_revision( request.operation, observation.state, observation.policy.as_ref(), &draft.metadata.id.0, - ) - .map_err(|message| error_response(ErrorCode::Conflict, message))?; - let policy = draft.into_policy_document(revision, Utc::now()).map_err(|_| { - error_response( - ErrorCode::ValidationFailed, - "failed to commit the validated policy draft", - ) - })?; - let bytes = serde_json::to_vec_pretty(&policy) - .map_err(|_| error_response(ErrorCode::InternalError, "failed to serialize the committed policy"))?; + ) { + Ok(revision) => revision, + Err(message) => { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::RevisionConflict, + ); + return Err(error_response(ErrorCode::Conflict, message)); + } + }; + let policy = match draft.into_policy_document(revision, Utc::now()) { + Ok(policy) => policy, + Err(_) => { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::DraftCommitFailed, + ); + return Err(error_response( + ErrorCode::ValidationFailed, + "failed to commit the validated policy draft", + )); + } + }; + let bytes = match serde_json::to_vec_pretty(&policy) { + Ok(bytes) => bytes, + Err(_) => { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::SerializationFailed, + ); + return Err(error_response( + ErrorCode::InternalError, + "failed to serialize the committed policy", + )); + } + }; let persisted = if request.operation == PolicyReplacementOperation::Create { self.storage @@ -388,13 +432,20 @@ impl PolicyStore { tracing::warn!(error = format!("{error:#}"), "Policy persistence failed"); let (_, current) = self.observe_storage(false); if current.fingerprint != observation.fingerprint { - let management = self.publish_observation(current); + let audit_path = current.canonical_path.clone(); + let management = self.publish_external_observation(current); + audit.failed_at(operation, &audit_path, crate::audit::FailureReason::StaleStoreToken); return Err(error_with_management( ErrorCode::StalePolicyStoreToken, "the policy storage changed before publication; retry with the current store token", management, )); } + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::PersistenceFailed, + ); return Err(error_response( ErrorCode::PolicyPersistenceFailed, "failed to persist the policy", @@ -407,12 +458,19 @@ impl PolicyStore { ); let (_, current) = self.observe_storage(false); if current.fingerprint == observation.fingerprint { + audit.failed_at( + operation, + &observation.canonical_path, + crate::audit::FailureReason::ConditionalPublicationFailed, + ); return Err(error_response( ErrorCode::PolicyPersistenceFailed, "failed to conditionally persist the policy", )); } - let management = self.publish_observation(current); + let audit_path = current.canonical_path.clone(); + let management = self.publish_external_observation(current); + audit.failed_at(operation, &audit_path, crate::audit::FailureReason::StaleStoreToken); return Err(error_with_management( ErrorCode::StalePolicyStoreToken, "the policy storage changed during publication; retry with the current store token", @@ -425,7 +483,9 @@ impl PolicyStore { "Published policy failed authoritative reload" ); let (_, current) = self.observe_storage(false); - let management = self.publish_observation(current); + let audit_path = current.canonical_path.clone(); + let management = self.publish_external_observation(current); + audit.failed_at(operation, &audit_path, crate::audit::FailureReason::ActivationFailed); return Err(error_with_management( ErrorCode::PolicyActivationFailed, "the policy was published but failed authoritative reload", @@ -434,6 +494,9 @@ impl PolicyStore { } }; + let old_id = observation.policy.as_ref().map(|policy| policy.metadata.id.0.clone()); + let old_revision = observation.policy.as_ref().map(|policy| policy.metadata.revision); + let canonical_path = observation.canonical_path.clone(); let token = token_for(&previous, &persisted.fingerprint); let snapshot = Arc::new(Snapshot { state: PolicyManagementState::Active, @@ -447,6 +510,16 @@ impl PolicyStore { }); *self.snapshot.write().expect("policy store snapshot lock poisoned") = snapshot; + audit.succeeded_at( + &canonical_path, + old_id.as_deref(), + old_revision, + &persisted.policy.metadata.id.0, + persisted.policy.metadata.revision, + operation, + request.conflict_handling == PolicyConflictHandling::ConfirmOverwrite, + ); + Ok(ReplaceSuccess { policy: persisted.policy, validation, @@ -469,6 +542,34 @@ impl PolicyStore { management } + fn publish_external_observation(&self, observation: Observation) -> PolicyManagementSnapshot { + let policy_changed = self.snapshot().fingerprint != observation.fingerprint; + let management = self.publish_observation(observation); + if policy_changed { + let path = Path::new(&management.configured_path); + match (management.state, management.policy.as_ref()) { + (PolicyManagementState::Active, Some(policy)) => { + crate::audit::external_change_applied(path, &policy.metadata.id.0, policy.metadata.revision); + } + (PolicyManagementState::Missing | PolicyManagementState::Invalid, _) => { + crate::audit::external_change_rejected(path, management.state); + } + (PolicyManagementState::Active, None) => { + crate::audit::external_change_rejected(path, PolicyManagementState::Invalid); + } + } + } + management + } + + #[cfg(test)] + pub(crate) async fn replace_for_tests( + &self, + request: PolicyReplacementRequest, + ) -> Result { + self.replace(request, crate::audit::tests::noop()).await + } + #[cfg(test)] pub(crate) fn for_tests(policy: Option) -> Arc { let storage = Arc::new(TestStorage::new(policy)); @@ -809,6 +910,7 @@ fn clone_observation(observation: &Observation) -> Observation { mod storage_tests { use now_policy::PolicyDraftDocument; use now_policy_api::{PolicyConflictHandling, PolicyReplacementRequestKind}; + use win_api_wrappers::identity::sid::Sid; use super::*; @@ -847,12 +949,126 @@ mod storage_tests { expected_store_token: store.management_snapshot().store_token, operation: PolicyReplacementOperation::Update, conflict_handling: PolicyConflictHandling::Reject, - warnings_acknowledged: false, draft: raw, validation_receipt: validation.validation_receipt.expect("valid receipt"), } } + fn recording_audit() -> (crate::audit::WriteAudit, Arc) { + let sid = + Sid::from_well_known(::windows::Win32::Security::WinLocalSystemSid, None).expect("resolve SYSTEM SID"); + crate::audit::tests::begin(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json")) + } + + #[tokio::test] + async fn audited_old_validator_receipt_fails_once_without_publication() { + let store = PolicyStore::load_with_storage( + Some(PathBuf::from(r"C:\policy.json")), + Arc::new(TestStorage::new(Some(policy("current", 1)))), + Monitoring::Available, + ); + let mut request = update_request(&store); + let validation = store.validate_draft(&request.draft); + let canonical = validation.canonical_draft.as_ref().expect("canonical draft"); + request.validation_receipt = + store + .receipt_key + .issue("now-package-broker-policy-validator/8", canonical, &validation.findings); + let (audit, recorder) = recording_audit(); + + let error = store + .replace(request, audit) + .await + .expect_err("old validator receipt is rejected"); + + assert_eq!(error.code, ErrorCode::ValidationFailed); + assert_eq!(store.active_policy().expect("unchanged policy").metadata.revision, 1); + assert_eq!( + recorder + .events() + .iter() + .map(|entry| entry.event_code) + .collect::>(), + [ + Some(agent_sysevent_codes::POLICY_WRITE_ATTEMPTED), + Some(agent_sysevent_codes::POLICY_CHANGE_FAILED) + ] + ); + assert!( + recorder.events()[1] + .fields + .iter() + .any(|(name, value)| name == "reason" && value == "invalid_receipt") + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn canonical_external_observations_are_audited_once_per_change() { + let storage = Arc::new(TestStorage::new(Some(policy("current", 1)))); + let store = PolicyStore::load_with_storage( + Some(PathBuf::from(r"C:\policy.json")), + Arc::clone(&storage) as Arc, + Monitoring::Available, + ); + crate::audit::tests::take_events(); + + store.reload_from_disk(ReloadCause::ExternalChange).await; + assert!( + crate::audit::tests::take_events().is_empty(), + "unchanged policy is not an event" + ); + + storage.set_disk_state(None, true, 2); + let rejected = store.reload_from_disk(ReloadCause::ExternalChange).await; + assert_eq!(rejected.state, PolicyManagementState::Invalid); + assert!( + store.active_policy().is_none(), + "invalid external policy is not published" + ); + let events = crate::audit::tests::take_events(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0].event_code, + Some(agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_REJECTED) + ); + assert!( + events[0] + .fields + .iter() + .any(|(name, value)| name == "reason" && value == "invalid") + ); + + store.reload_from_disk(ReloadCause::ExternalChange).await; + assert!( + crate::audit::tests::take_events().is_empty(), + "unchanged invalid policy is not an event" + ); + + storage.set_disk_state(Some(policy("external", 7)), false, 3); + let applied = store.reload_from_disk(ReloadCause::ExternalChange).await; + assert_eq!(applied.state, PolicyManagementState::Active); + assert_eq!( + store + .active_policy() + .expect("external policy is active") + .metadata + .revision, + 7 + ); + let events = crate::audit::tests::take_events(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0].event_code, + Some(agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED) + ); + + store.reload_from_disk(ReloadCause::ExternalChange).await; + assert!( + crate::audit::tests::take_events().is_empty(), + "unchanged external policy is not an event" + ); + } + #[tokio::test] async fn compatible_format_version_is_bound_to_receipts_and_persisted_tokens() { let storage = Arc::new(TestStorage::new(Some(policy("current", 1)))); @@ -864,7 +1080,7 @@ mod storage_tests { let mut request = update_request(&store); request.draft["PolicyFormatVersion"] = serde_json::json!("1.7.3"); let error = store - .replace(request.clone()) + .replace_for_tests(request.clone()) .await .expect_err("format version is receipt-bound"); assert_eq!(error.code, ErrorCode::ValidationFailed); @@ -878,14 +1094,17 @@ mod storage_tests { .issue("now-package-broker-policy-validator/8", canonical, &validation.findings); request.validation_receipt = old_receipt; let error = store - .replace(request.clone()) + .replace_for_tests(request.clone()) .await .expect_err("old validator receipt is rejected"); assert_eq!(error.code, ErrorCode::ValidationFailed); request.validation_receipt = validation.validation_receipt.expect("current receipt"); let before = store.management_snapshot().store_token; - let result = store.replace(request).await.expect("compatible format is writable"); + let result = store + .replace_for_tests(request) + .await + .expect("compatible format is writable"); assert_eq!( serde_json::to_value(&result.policy).expect("serialize committed policy")["PolicyFormatVersion"], "1.7.3" @@ -900,8 +1119,9 @@ mod storage_tests { ); } - #[tokio::test] + #[tokio::test(flavor = "current_thread")] async fn concurrent_external_replacement_is_preserved_and_published() { + crate::audit::tests::take_events(); let storage = Arc::new(TestStorage::new(Some(policy("current", 1)))); let store = PolicyStore::load_with_storage( Some(PathBuf::from(r"C:\policy.json")), @@ -909,9 +1129,13 @@ mod storage_tests { Monitoring::Available, ); let request = update_request(&store); + let (audit, recorder) = recording_audit(); storage.race_before_next_persist(policy("external", 7)); - let error = store.replace(request).await.expect_err("external replacement wins"); + let error = store + .replace(request, audit) + .await + .expect_err("external replacement wins"); assert_eq!(error.code, ErrorCode::StalePolicyStoreToken); assert_eq!( @@ -926,6 +1150,58 @@ mod storage_tests { .revision, 7 ); + assert_eq!( + crate::audit::tests::take_events() + .iter() + .map(|entry| entry.event_code) + .collect::>(), + [Some(agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED)] + ); + assert_eq!( + recorder + .events() + .iter() + .map(|entry| entry.event_code) + .collect::>(), + [ + Some(agent_sysevent_codes::POLICY_WRITE_ATTEMPTED), + Some(agent_sysevent_codes::POLICY_CHANGE_FAILED) + ] + ); + assert!( + recorder.events()[1] + .fields + .iter() + .any(|(name, value)| name == "outcome" && value == "stale_conflict") + ); + } + + #[tokio::test] + async fn audited_replacement_records_one_success_after_activation() { + let store = PolicyStore::load_with_storage( + Some(PathBuf::from(r"C:\policy.json")), + Arc::new(TestStorage::new(Some(policy("current", 1)))), + Monitoring::Available, + ); + let (audit, recorder) = recording_audit(); + + let success = store + .replace(update_request(&store), audit) + .await + .expect("replacement succeeds"); + + assert_eq!(success.policy.metadata.revision, 2); + assert_eq!( + recorder + .events() + .iter() + .map(|entry| entry.event_code) + .collect::>(), + [ + Some(agent_sysevent_codes::POLICY_WRITE_ATTEMPTED), + Some(agent_sysevent_codes::POLICY_CHANGE_SUCCEEDED) + ] + ); } #[tokio::test] @@ -942,7 +1218,10 @@ mod storage_tests { .fail_concurrent_check .store(true, std::sync::atomic::Ordering::SeqCst); - let error = store.replace(request).await.expect_err("identity check fails"); + let error = store + .replace_for_tests(request) + .await + .expect_err("identity check fails"); assert_eq!(error.code, ErrorCode::PolicyPersistenceFailed); assert_eq!(store.management_snapshot().store_token, previous_token); @@ -970,7 +1249,10 @@ mod storage_tests { .fail_target_retention .store(true, std::sync::atomic::Ordering::SeqCst); - let error = store.replace(request).await.expect_err("target retention fails"); + let error = store + .replace_for_tests(request) + .await + .expect_err("target retention fails"); assert_eq!(error.code, ErrorCode::PolicyPersistenceFailed); assert_eq!(store.management_snapshot().store_token, previous_token); @@ -996,7 +1278,10 @@ mod storage_tests { *storage.post_persist_capability.lock() = Some((PolicyWriteCapability::ReadOnly, Some(PolicyReadOnlyReason::UnsafePath))); - let success = store.replace(request).await.expect("policy replacement succeeds"); + let success = store + .replace_for_tests(request) + .await + .expect("policy replacement succeeds"); assert_eq!(success.management.write_capability, PolicyWriteCapability::ReadOnly); assert_eq!( @@ -1022,7 +1307,10 @@ mod storage_tests { ); assert_eq!(store.watched_path(), canonical); - let success = store.replace(update_request(&store)).await.expect("replace policy"); + let success = store + .replace_for_tests(update_request(&store)) + .await + .expect("replace policy"); assert_eq!(&*storage.persisted_configured_paths.lock(), &[configured]); assert_eq!(store.watched_path(), canonical); diff --git a/crates/now-package-broker/src/policy_store/receipt.rs b/crates/now-package-broker/src/policy_store/receipt.rs index 4aec0b184..300c416fb 100644 --- a/crates/now-package-broker/src/policy_store/receipt.rs +++ b/crates/now-package-broker/src/policy_store/receipt.rs @@ -115,7 +115,6 @@ mod tests { expected_store_token: store.management_snapshot().store_token, operation, conflict_handling: PolicyConflictHandling::Reject, - warnings_acknowledged: false, draft: raw, validation_receipt: validation.validation_receipt.expect("valid receipt"), } @@ -199,7 +198,10 @@ mod tests { let mut stale_request = request(&store, PolicyReplacementOperation::Update, raw.clone()); storage.set_disk_state(Some(policy("retargeted", 9)), false, 9); stale_request.conflict_handling = PolicyConflictHandling::ConfirmOverwrite; - let stale_error = store.replace(stale_request).await.expect_err("stale token rejected"); + let stale_error = store + .replace_for_tests(stale_request) + .await + .expect_err("stale token rejected"); assert_eq!(stale_error.code, ErrorCode::StalePolicyStoreToken); assert!(stale_error.management.is_some()); assert_eq!( @@ -208,11 +210,14 @@ mod tests { ); let mut tampered = request(&store, PolicyReplacementOperation::Update, raw); tampered.draft["Metadata"]["Publisher"] = "Tampered".into(); - let receipt_error = store.replace(tampered).await.expect_err("tampered draft rejected"); + let receipt_error = store + .replace_for_tests(tampered) + .await + .expect_err("tampered draft rejected"); assert_eq!(receipt_error.code, ErrorCode::ValidationFailed); } #[tokio::test] - async fn store_requires_warning_acknowledgement() { + async fn store_saves_valid_drafts_with_advisory_findings() { let store = PolicyStore::for_tests(None); let mut risky = serde_json::to_value(draft("risky")).expect("serialize draft"); risky["Rules"] = serde_json::Value::Array( @@ -251,14 +256,11 @@ mod tests { serde_json::to_value(round_trip).expect("serialize round-tripped validation result"), serialized ); - let mut replacement = request(&store, PolicyReplacementOperation::Create, risky); - let error = store - .replace(replacement.clone()) + let replacement = request(&store, PolicyReplacementOperation::Create, risky); + store + .replace_for_tests(replacement) .await - .expect_err("warning must be acknowledged"); - assert_eq!(error.code, ErrorCode::WarningConfirmationRequired); - replacement.warnings_acknowledged = true; - store.replace(replacement).await.expect("acknowledged warning succeeds"); + .expect("advisory findings do not block a valid draft"); } #[tokio::test] async fn canonical_sensitive_warnings_accept_the_original_receipt() { @@ -309,7 +311,6 @@ mod tests { expected_store_token: store.management_snapshot().store_token, operation: PolicyReplacementOperation::Create, conflict_handling: PolicyConflictHandling::Reject, - warnings_acknowledged: true, draft: canonical.clone(), validation_receipt: receipt.clone(), }; @@ -317,13 +318,13 @@ mod tests { let mut changed = replacement.clone(); changed.draft["Rules"][0]["Constraints"]["AllowSkipHashCheck"] = serde_json::json!(false); let error = store - .replace(changed) + .replace_for_tests(changed) .await .expect_err("meaningful option change invalidates receipt"); assert_eq!(error.code, ErrorCode::ValidationFailed); } store - .replace(replacement) + .replace_for_tests(replacement) .await .unwrap_or_else(|error| panic!("{option} via {explicit} failed: {error:?}")); } @@ -334,21 +335,21 @@ mod tests { let create = PolicyStore::for_tests(None); let raw = serde_json::to_value(draft("created")).expect("serialize draft"); let created = create - .replace(request(&create, PolicyReplacementOperation::Create, raw)) + .replace_for_tests(request(&create, PolicyReplacementOperation::Create, raw)) .await .expect("create succeeds"); assert_eq!(created.policy.metadata.revision, 1); let update = PolicyStore::for_tests(Some(policy("current", 7))); let raw = serde_json::to_value(draft("current")).expect("serialize draft"); let updated = update - .replace(request(&update, PolicyReplacementOperation::Update, raw)) + .replace_for_tests(request(&update, PolicyReplacementOperation::Update, raw)) .await .expect("update succeeds"); assert_eq!(updated.policy.metadata.revision, 8); let replace = PolicyStore::for_tests(Some(policy("current", 7))); let raw = serde_json::to_value(draft("replacement")).expect("serialize draft"); let replaced = replace - .replace(request(&replace, PolicyReplacementOperation::ReplaceIdentity, raw)) + .replace_for_tests(request(&replace, PolicyReplacementOperation::ReplaceIdentity, raw)) .await .expect("identity replacement succeeds"); assert_eq!(replaced.policy.metadata.revision, 1); @@ -360,14 +361,14 @@ mod tests { ); let raw = serde_json::to_value(draft("repaired")).expect("serialize draft"); let repaired = repair - .replace(request(&repair, PolicyReplacementOperation::Repair, raw)) + .replace_for_tests(request(&repair, PolicyReplacementOperation::Repair, raw)) .await .expect("repair succeeds"); assert_eq!(repaired.policy.metadata.revision, 1); let wrong_identity = PolicyStore::for_tests(Some(policy("current", 1))); let raw = serde_json::to_value(draft("different")).expect("serialize draft"); let error = wrong_identity - .replace(request(&wrong_identity, PolicyReplacementOperation::Update, raw)) + .replace_for_tests(request(&wrong_identity, PolicyReplacementOperation::Update, raw)) .await .expect_err("update must preserve identity"); assert_eq!(error.code, ErrorCode::Conflict); @@ -377,7 +378,7 @@ mod tests { let store = PolicyStore::for_tests(Some(policy("current", 1))); let raw = serde_json::to_value(draft("current")).expect("serialize draft"); let first = request(&store, PolicyReplacementOperation::Update, raw); - let (first, second) = tokio::join!(store.replace(first.clone()), store.replace(first)); + let (first, second) = tokio::join!(store.replace_for_tests(first.clone()), store.replace_for_tests(first)); let outcomes = [first, second]; assert_eq!(outcomes.iter().filter(|result| result.is_ok()).count(), 1); assert_eq!( @@ -399,7 +400,7 @@ mod tests { storage.fail_persist.store(true, std::sync::atomic::Ordering::SeqCst); let raw = serde_json::to_value(draft("current")).expect("serialize draft"); let error = store - .replace(request(&store, PolicyReplacementOperation::Update, raw)) + .replace_for_tests(request(&store, PolicyReplacementOperation::Update, raw)) .await .expect_err("persistence failure"); assert_eq!(error.code, ErrorCode::PolicyPersistenceFailed); @@ -470,7 +471,7 @@ mod tests { ); replacement.expected_store_token = token; let error = store - .replace(replacement) + .replace_for_tests(replacement) .await .expect_err("monitoring failure blocks PUT"); assert_eq!(error.code, ErrorCode::BrokerPaused); diff --git a/crates/now-package-broker/src/policy_store/validation.rs b/crates/now-package-broker/src/policy_store/validation.rs index 2f0771679..c24cf8da7 100644 --- a/crates/now-package-broker/src/policy_store/validation.rs +++ b/crates/now-package-broker/src/policy_store/validation.rs @@ -10,6 +10,8 @@ use now_policy_api::{ API_VERSION_STR, PolicyFinding, PolicyFindingCode, PolicyFindingSeverity, PolicyValidationResult, }; +use crate::evaluator; + pub(super) const VALIDATOR_VERSION: &str = "now-package-broker-policy-validator/10"; const MAX_RULES: usize = 1024; const MAX_RULE_PRIORITY: u32 = i32::MAX as u32; @@ -419,6 +421,17 @@ fn check_rule(index: usize, rule: &PolicyRule, findings: &mut Findings) { if let Some(reason) = &rule.reason { check_string_len(reason, 0, 512, &format!("{base}/Reason"), findings); } + for (source_index, source_name) in rule.match_criteria.source_names.iter().enumerate() { + if !evaluator::source_name_is_unambiguous(source_name.as_ref()) { + findings.push(rule_finding( + rule, + PolicyFindingSeverity::Error, + PolicyFindingCode::InvalidFieldValue, + format!("{base}/Match/SourceNames/{source_index}"), + "SourceNames must not contain leading, trailing, or default-ignorable characters", + )); + } + } if let Some(PackageIdentifierCondition::Patterns(patterns)) = &rule.match_criteria.package_identifiers { check_patterns( index, @@ -709,6 +722,34 @@ mod tests { assert_eq!(canonical.pointer("/Rules/0/Match/Interactive"), Some(&json!(false))); } + #[test] + fn source_names_reject_ambiguous_spellings() { + for source_name in ["PSGallery ", " PSGallery", "PS\u{00AD}Gallery"] { + let mut raw = draft(); + raw["Rules"] = json!([rule( + "deny", + json!({ "Managers": ["PowerShell"], "SourceNames": [source_name] }) + )]); + + let result = validate_draft(&raw); + + assert!(!result.is_valid, "{source_name:?} must be rejected"); + assert!( + result + .findings + .iter() + .any(|finding| finding.path == "/Rules/0/Match/SourceNames/0") + ); + } + + let mut valid = draft(); + valid["Rules"] = json!([rule( + "deny", + json!({ "Managers": ["PowerShell"], "SourceNames": ["PSGallery"] }) + )]); + assert!(validate_draft(&valid).is_valid); + } + #[test] fn shared_contract_rejects_invalid_rule_shapes() { let cases = [ diff --git a/crates/now-package-broker/src/policy_store/windows.rs b/crates/now-package-broker/src/policy_store/windows.rs index 972029cf2..52b802742 100644 --- a/crates/now-package-broker/src/policy_store/windows.rs +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -34,10 +34,11 @@ use windows::Win32::Storage::FileSystem::{ CREATE_NEW, CreateFileW, DELETE, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, FILE_DISPOSITION_FLAG_DELETE, FILE_DISPOSITION_FLAG_IGNORE_READONLY_ATTRIBUTE, FILE_DISPOSITION_FLAG_POSIX_SEMANTICS, FILE_DISPOSITION_INFO_EX, FILE_DISPOSITION_INFO_EX_FLAGS, - FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_FLAG_WRITE_THROUGH, FILE_GENERIC_READ, - FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES, FILE_RENAME_INFO, FILE_RENAME_INFO_0, FILE_SHARE_DELETE, - FILE_SHARE_NONE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TRAVERSE, FileDispositionInfoEx, FileRenameInfo, - FileRenameInfoEx, GetVolumeInformationW, GetVolumePathNameW, READ_CONTROL, SetFileInformationByHandle, + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_DELETE_ON_CLOSE, FILE_FLAG_OPEN_REPARSE_POINT, FILE_FLAG_WRITE_THROUGH, + FILE_GENERIC_READ, FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES, FILE_RENAME_INFO, FILE_RENAME_INFO_0, + FILE_SHARE_DELETE, FILE_SHARE_NONE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TRAVERSE, FileDispositionInfoEx, + FileRenameInfo, FileRenameInfoEx, GetVolumeInformationW, GetVolumePathNameW, READ_CONTROL, + SetFileInformationByHandle, }; #[cfg(test)] use windows::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MoveFileExW}; @@ -544,6 +545,11 @@ impl std::error::Error for UnsupportedAtomicSemantics {} /// Filesystem names known to support atomic same-directory handle renames. /// Conservative by design: an unrecognized filesystem is treated as unsupported. const ATOMIC_REPLACE_CAPABLE_FILESYSTEMS: &[&str] = &["NTFS", "ReFS"]; +const PROBE_SOURCE_NAME: &str = ".package-broker-write-probe-a.tmp"; +const PROBE_TARGET_NAME: &str = ".package-broker-write-probe-b.tmp"; +const PROBE_TOMBSTONE_NAME: &str = ".package-broker-write-probe-old.tmp"; +const PROBE_SOURCE_CONTENT: &[u8] = b"probe-source"; +const PROBE_TARGET_CONTENT: &[u8] = b"probe-target"; /// Verifies that `dir` supports the handle-based tombstone and create-new publication semantics required by [`atomic_replace`]. /// @@ -559,11 +565,12 @@ fn probe_write_capability(dir: &Path) -> anyhow::Result<()> { } let dir_handle = open_directory_no_reparse(dir)?; - let source_path = dir.join(".package-broker-write-probe-a.tmp"); - let target_path = dir.join(".package-broker-write-probe-b.tmp"); - let tombstone_path = dir.join(".package-broker-write-probe-old.tmp"); - let source = create_probe_file(&source_path, b"probe-source", false)?; - let target = match create_probe_file(&target_path, b"probe-target", true) { + recover_interrupted_write_capability_probe(&dir_handle, dir)?; + let source_path = dir.join(PROBE_SOURCE_NAME); + let target_path = dir.join(PROBE_TARGET_NAME); + let tombstone_path = dir.join(PROBE_TOMBSTONE_NAME); + let source = create_probe_file(&source_path, PROBE_SOURCE_CONTENT, false)?; + let target = match create_probe_file(&target_path, PROBE_TARGET_CONTENT, true) { Ok(target) => target, Err(error) => { return match cleanup_probe_file(source, &source_path, "write-capability probe source") { @@ -595,7 +602,7 @@ fn probe_write_capability(dir: &Path) -> anyhow::Result<()> { source_published = true; let replaced = std::fs::read(&target_path).context("read write-capability probe result")?; ensure!( - replaced == b"probe-source", + replaced == PROBE_SOURCE_CONTENT, "atomic replacement did not take effect on this filesystem" ); delete_file_handle(&target).context("probe POSIX tombstone unlink")?; @@ -620,6 +627,59 @@ fn probe_write_capability(dir: &Path) -> anyhow::Result<()> { probe_result.and(source_cleanup).and(target_cleanup) } +/// Retire a trusted remnant from a capability probe interrupted before its +/// delete-on-close handles were released. +/// +/// The fixed names are only reclaimable when the entry is a non-reparse, +/// single-link, managed-policy file containing one of the probe's exact payloads. +/// This preserves fail-closed collision handling for untrusted lookalikes. +fn recover_interrupted_write_capability_probe(dir: &File, dir_path: &Path) -> anyhow::Result<()> { + verify_directory_handle_type(dir, "write-capability probe directory")?; + + for (name, expected_contents) in [ + (PROBE_SOURCE_NAME, &[PROBE_SOURCE_CONTENT][..]), + (PROBE_TARGET_NAME, &[PROBE_TARGET_CONTENT, PROBE_SOURCE_CONTENT][..]), + (PROBE_TOMBSTONE_NAME, &[PROBE_TARGET_CONTENT][..]), + ] { + let path = dir_path.join(name); + let file = match OpenOptions::new() + .access_mode(FILE_GENERIC_READ.0 | DELETE.0 | READ_CONTROL.0) + .share_mode(FILE_SHARE_READ.0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0) + .open(&path) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(error) + .with_context(|| format!("failed to open interrupted write probe {}", path.display())); + } + }; + + policy_security::verify_policy_file_path(&file, &path) + .with_context(|| format!("interrupted write probe {} is unsafe", path.display()))?; + policy_security::verify_managed_policy_file_security(&file) + .with_context(|| format!("interrupted write probe {} has unsafe security", path.display()))?; + ensure!( + policy_security::file_link_count(&file)? == 1, + "interrupted write probe {} has multiple hard links", + path.display() + ); + let content = read_file_from_start(&file) + .with_context(|| format!("failed to read interrupted write probe {}", path.display()))?; + ensure!( + expected_contents.contains(&content.as_slice()), + "interrupted write probe {} has unexpected content", + path.display() + ); + delete_file_handle(&file) + .with_context(|| format!("failed to retire interrupted write probe {}", path.display()))?; + drop(file); + ensure_path_absent(&path, "interrupted write probe")?; + } + Ok(()) +} + fn verify_no_replace_collision( source: &File, target: &File, @@ -775,7 +835,7 @@ fn create_probe_file(path: &Path, bytes: &[u8], allow_delete_share: bool) -> any } .0, ) - .custom_flags((FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_WRITE_THROUGH).0) + .custom_flags((FILE_FLAG_DELETE_ON_CLOSE | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_WRITE_THROUGH).0) .open(path) .with_context(|| format!("failed to create write-capability probe {}", path.display()))?; if let Err(error) = file @@ -4148,7 +4208,7 @@ mod tests { // ─── probe_write_capability / volume_filesystem_name ────────────────────── // - // No elevation required: these never touch `admin_only_security_attributes`. + // No elevation required: ordinary probes use inherited directory security. #[test] fn volume_filesystem_name_reports_a_known_filesystem_for_a_temp_directory() { @@ -4171,6 +4231,54 @@ mod tests { assert!(leftover.is_empty(), "probe left files behind: {leftover:?}"); } + #[test] + fn probe_file_is_removed_when_its_handle_closes() { + let dir = temp_dir(); + let path = dir.path().join(PROBE_SOURCE_NAME); + let file = create_probe_file(&path, PROBE_SOURCE_CONTENT, false).unwrap(); + + drop(file); + + assert!(!path.exists(), "delete-on-close probe file survived its handle"); + } + + #[test] + fn interrupted_trusted_probe_remnant_is_recovered() { + use std::io::Write as _; + + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let path = dir.path().join(PROBE_SOURCE_NAME); + let mut file = match create_secure_transaction_file(&path) { + Ok(file) => file, + Err(error) => { + tracing::warn!( + error = %format!("{error:#}"), + "Skipping administrator-owned probe recovery fixture" + ); + return; + } + }; + file.write_all(PROBE_SOURCE_CONTENT).unwrap(); + file.sync_all().unwrap(); + drop(file); + + recover_interrupted_write_capability_probe(&dir_file, dir.path()).unwrap(); + + assert!(!path.exists(), "trusted interrupted probe remnant was not retired"); + } + + #[test] + fn interrupted_untrusted_probe_remnant_is_not_removed() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let path = dir.path().join(PROBE_SOURCE_NAME); + std::fs::write(&path, PROBE_SOURCE_CONTENT).unwrap(); + + assert!(recover_interrupted_write_capability_probe(&dir_file, dir.path()).is_err()); + assert!(path.exists(), "untrusted probe collision must remain fail-closed"); + } + #[test] fn occupied_no_replace_probe_preserves_both_retained_files() { let dir = temp_dir(); diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs index 61664d16c..9d5fcef8c 100644 --- a/crates/now-package-broker/src/server/mod.rs +++ b/crates/now-package-broker/src/server/mod.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::fmt; +use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -25,6 +26,7 @@ use now_policy_api::{ use now_policy_server_template::{ MAX_POLICY_MANAGEMENT_BODY_BYTES, MAX_REQUEST_BODY_BYTES, PackageBrokerServer, SharedPackageBrokerServer, }; +use tokio::sync::watch; use tracing::{info, trace, warn}; use win_api_wrappers::identity::sid::Sid; @@ -48,6 +50,7 @@ use responses::{ // The unit value marks the scope in which an authenticated policy management request is dispatched. tokio::task_local! { static POLICY_MANAGEMENT_AUTHENTICATED: (); + static POLICY_WRITE_AUDIT: crate::audit::WriteAudit; } /// How long a per-user manager availability probe stays fresh before it is re-run. @@ -110,7 +113,25 @@ struct EvaluatedRequest { } /// Build the axum router for a single authenticated pipe client. +#[cfg(test)] pub(crate) fn build_router_for_client(state: Arc, client: PipeClient) -> axum::Router { + build_router_for_client_with_optional_policy_write_deadline(state, client, None) +} + +/// Build the router and signal when the exact helper policy-write authorization completes. +pub(crate) fn build_router_for_client_with_policy_write_deadline( + state: Arc, + client: PipeClient, + policy_write_deadline: watch::Sender, +) -> axum::Router { + build_router_for_client_with_optional_policy_write_deadline(state, client, Some(policy_write_deadline)) +} + +fn build_router_for_client_with_optional_policy_write_deadline( + state: Arc, + client: PipeClient, + policy_write_deadline: Option>, +) -> axum::Router { let server: SharedPackageBrokerServer = Arc::new(BrokerConnection { state: Arc::clone(&state), client: client.clone(), @@ -118,6 +139,7 @@ pub(crate) fn build_router_for_client(state: Arc, client: PipeClien axum::Router::from(now_policy_server_template::api_router_from_shared(server)) .layer(middleware::from_fn(reject_duplicate_policy_json_members)) .layer(middleware::from_fn_with_state(state, authenticate_policy_management)) + .layer(Extension(policy_write_deadline)) .layer(Extension(client)) } @@ -290,9 +312,14 @@ fn reject_duplicate_json_members(bytes: &[u8]) -> Result<(), serde_json::Error> async fn authenticate_policy_management( State(state): State>, Extension(client): Extension, + Extension(policy_write_deadline): Extension>>, request: Request, next: Next, ) -> Response { + let write_audit = matches!((request.method(), request.uri().path()), (&Method::PUT, "/v1/policy")).then(|| { + let configured_path = PathBuf::from(state.policy_store.management_snapshot().configured_path); + crate::audit::WriteAudit::begin(client.user_sid(), client.executable_path(), &configured_path) + }); let protected = matches!( (request.method(), request.uri().path()), (&Method::GET, "/v1/policy/management") @@ -301,7 +328,16 @@ async fn authenticate_policy_management( | (&Method::PUT, "/v1/policy") ); if protected { - if let Err(error) = client.validate_connection(state.skip_signature_validation) { + let policy_write = matches!((request.method(), request.uri().path()), (&Method::PUT, "/v1/policy")); + let authentication = if policy_write { + client.validate_policy_write(state.skip_signature_validation) + } else { + client.validate_connection(state.skip_signature_validation) + }; + if let Err(error) = authentication { + if let Some(audit) = write_audit { + audit.denied(crate::audit::DenialReason::AuthenticationFailed); + } warn!(error = format!("{error:#}"), "Rejected policy management request"); return ( StatusCode::UNAUTHORIZED, @@ -312,7 +348,15 @@ async fn authenticate_policy_management( ) .into_response(); } - return POLICY_MANAGEMENT_AUTHENTICATED.scope((), next.run(request)).await; + if policy_write && let Some(policy_write_deadline) = policy_write_deadline { + let _ = policy_write_deadline.send(true); + } + let authenticated = POLICY_MANAGEMENT_AUTHENTICATED.scope((), next.run(request)); + return if let Some(audit) = write_audit { + POLICY_WRITE_AUDIT.scope(audit, authenticated).await + } else { + authenticated.await + }; } next.run(request).await } @@ -381,7 +425,11 @@ impl PackageBrokerServer for BrokerConnection { request: PolicyReplacementRequest, ) -> Result { require_policy_management_authentication()?; + let audit = POLICY_WRITE_AUDIT + .try_with(Clone::clone) + .map_err(|_| error_response(ErrorCode::InternalError, "policy write audit context is unavailable"))?; if !self.client.is_elevated_administrator() { + audit.denied(crate::audit::DenialReason::AdministratorRequired); return Err(error_response( ErrorCode::AdministratorRequired, "policy replacement requires an elevated Administrator", @@ -389,7 +437,7 @@ impl PackageBrokerServer for BrokerConnection { } self.state .policy_store - .replace(request) + .replace(request, audit) .await .map(|success| PolicyReplacementResponse { response_kind: now_policy_api::PolicyReplacementResponseKind, @@ -706,6 +754,17 @@ impl BrokerState { reason = "the shared API contract requires ErrorResponse values" )] fn evaluate_request(&self, request: &PackageRequest) -> Result { + if !evaluator::source_name_is_unambiguous(&request.source.name) { + warn!( + request_id = %request.request_id, + "Rejecting request: package source name has ambiguous spelling" + ); + return Err(error_response( + ErrorCode::ValidationFailed, + "package source name has unsupported leading, trailing, or default-ignorable characters", + )); + } + // SECURITY: Pre/post operation commands are raw command strings executed via // cmd.exe with the execution token, and the policy schema cannot restrict // their content yet. Running them elevated would grant arbitrary elevated @@ -1009,7 +1068,6 @@ mod tests { "ExpectedStoreToken": replacement_state.policy_store.management_snapshot().store_token, "Operation": "Create", "ConflictHandling": "Reject", - "WarningsAcknowledged": false, "Draft": replacement_draft, "ValidationReceipt": validation.validation_receipt.expect("valid receipt"), }); @@ -1036,7 +1094,7 @@ mod tests { Method::PUT, "/v1/policy", "Application/JSON; charset=utf-8", - r#"{"RequestKind":"PolicyReplacementRequest","RequestVersion":"1.0","ExpectedStoreToken":"invalid","Operation":"Create","ConflictHandling":"Reject","WarningsAcknowledged":false,"ValidationReceipt":"invalid","Draft":{"PolicyFormatVersion":"1.0.0","Metadata":{"Id":"created","Publisher":"Test","Publisher":"Test"},"Enforcement":{"DefaultDecision":"Deny"},"Rules":[]}}"#, + r#"{"RequestKind":"PolicyReplacementRequest","RequestVersion":"1.0","ExpectedStoreToken":"invalid","Operation":"Create","ConflictHandling":"Reject","ValidationReceipt":"invalid","Draft":{"PolicyFormatVersion":"1.0.0","Metadata":{"Id":"created","Publisher":"Test","Publisher":"Test"},"Enforcement":{"DefaultDecision":"Deny"},"Rules":[]}}"#, ), ] { let response = route_raw( @@ -1053,6 +1111,45 @@ mod tests { } } + #[cfg(feature = "dev-skip-broker-signature")] + #[tokio::test] + async fn authorized_policy_write_extends_only_its_connection_deadline() { + let client = PipeClient::test_with_authority(true, true).expect("create elevated test client"); + let state = shared_state(None); + let draft = serde_json::json!({ + "PolicyFormatVersion": "1.0.0", + "Metadata": { "Id": "replacement", "Publisher": "Test" }, + "Enforcement": { "DefaultDecision": "Deny" }, + "Rules": [] + }); + let validation = state.policy_store.validate_draft(&draft); + let replacement = serde_json::json!({ + "RequestKind": "PolicyReplacementRequest", + "RequestVersion": "1.0", + "ExpectedStoreToken": state.policy_store.management_snapshot().store_token, + "Operation": "Create", + "ConflictHandling": "Reject", + "Draft": draft, + "ValidationReceipt": validation.validation_receipt.expect("valid receipt"), + }); + let (deadline, mut extended) = watch::channel(false); + let mut router = build_router_for_client_with_policy_write_deadline(state, client, deadline); + let response = router + .call( + Request::builder() + .method(Method::PUT) + .uri("/v1/policy") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&replacement).expect("serialize request"))) + .expect("valid request"), + ) + .await + .expect("router is infallible"); + + assert_eq!(response.status(), StatusCode::OK); + assert!(*extended.borrow_and_update()); + } + #[cfg(feature = "dev-skip-broker-signature")] async fn route_json( state: Arc, @@ -1211,7 +1308,6 @@ mod tests { "ExpectedStoreToken": state.policy_store.management_snapshot().store_token, "Operation": "Create", "ConflictHandling": "Reject", - "WarningsAcknowledged": false, "Draft": draft, "ValidationReceipt": validation.validation_receipt.expect("valid receipt") }); diff --git a/crates/sysevent-codes/tests/message_catalog_parity.rs b/crates/sysevent-codes/tests/message_catalog_parity.rs new file mode 100644 index 000000000..4b65b680f --- /dev/null +++ b/crates/sysevent-codes/tests/message_catalog_parity.rs @@ -0,0 +1,117 @@ +//! Verifies that shared event codes and Windows message catalogs stay aligned. + +use std::path::Path; + +const MESSAGE_CATALOGS: &[&str] = &[ + "../../devolutions-gateway/devolutions-gateway.mc", + "../../devolutions-agent/devolutions-agent.mc", +]; + +#[test] +fn every_event_code_is_defined_once_in_every_catalog() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let event_codes = declared_event_codes(); + + for catalog in MESSAGE_CATALOGS { + let path = manifest_dir.join(catalog); + let content = + std::fs::read_to_string(&path).unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + + for (name, code) in &event_codes { + let expected_id = format!("MessageId={code}"); + let expected_name = format!("SymbolicName={name}"); + let positions: Vec<_> = content.match_indices(&expected_id).collect(); + assert_eq!( + positions.len(), + 1, + "{}: expected one {expected_id}, found {}", + path.display(), + positions.len() + ); + + let after_id = &content[positions[0].0..]; + let name_line = after_id.lines().nth(1).unwrap_or_default(); + assert_eq!( + name_line.trim(), + expected_name, + "{}: {expected_id} must be followed by {expected_name}", + path.display() + ); + } + } +} + +#[test] +fn every_catalog_message_terminates_each_translation() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + for catalog in MESSAGE_CATALOGS { + let path = manifest_dir.join(catalog); + let content = std::fs::read_to_string(&path).expect("read message catalog"); + assert!( + content.starts_with('\u{feff}'), + "{}: mc.exe requires a UTF-8 BOM to avoid decoding translations as ANSI", + path.display() + ); + for (_, code) in declared_event_codes() { + let mut lines = message_block(&content, code).lines(); + let mut languages = Vec::new(); + while let Some(line) = lines.next() { + let Some(language) = line.strip_prefix("Language=") else { + continue; + }; + languages.push(language); + let mut terminated = false; + for text in lines.by_ref() { + if text == "." { + terminated = true; + break; + } + assert!( + !text.starts_with("Language="), + "{}: MessageId={code} {language} lacks a message terminator", + path.display() + ); + } + assert!( + terminated, + "{}: MessageId={code} {language} lacks a message terminator", + path.display() + ); + } + languages.sort_unstable(); + assert_eq!( + languages, + ["English", "French", "German"], + "{}: MessageId={code} must define each translation once", + path.display() + ); + } + } +} + +fn declared_event_codes() -> Vec<(&'static str, u32)> { + include_str!("../src/lib.rs") + .lines() + .filter_map(|line| line.trim().strip_prefix("pub const ")) + .map(|declaration| { + let (name, value) = declaration + .split_once(": u32 = ") + .unwrap_or_else(|| panic!("event code must use `pub const NAME: u32 = VALUE;`: {declaration}")); + let value = value + .split_once(';') + .unwrap_or_else(|| panic!("event code must contain a semicolon: {declaration}")) + .0 + .parse() + .unwrap_or_else(|error| panic!("event code must be a decimal u32 in `{declaration}`: {error}")); + (name, value) + }) + .collect() +} + +fn message_block(content: &str, code: u32) -> &str { + let marker = format!("MessageId={code}"); + let start = content.find(&marker).unwrap_or_else(|| panic!("missing {marker}")); + let after = &content[start + marker.len()..]; + let end = after.find("\nMessageId=").unwrap_or(after.len()); + &content[start..start + marker.len() + end] +} diff --git a/devolutions-agent/Cargo.toml b/devolutions-agent/Cargo.toml index 2be961bb6..059b57741 100644 --- a/devolutions-agent/Cargo.toml +++ b/devolutions-agent/Cargo.toml @@ -93,6 +93,7 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls- thiserror = "2" uuid = { version = "1.17", features = ["v4"] } win-api-wrappers = { path = "../crates/win-api-wrappers" } +windows-registry = "0.5" [target.'cfg(windows)'.dependencies.windows] version = "0.61" diff --git a/devolutions-agent/build.rs b/devolutions-agent/build.rs index b8d9ad669..96da29750 100644 --- a/devolutions-agent/build.rs +++ b/devolutions-agent/build.rs @@ -3,6 +3,9 @@ fn main() { #[cfg(target_os = "windows")] win::embed_version_rc(); + + #[cfg(target_os = "windows")] + win::embed_devolutions_agent_mc(); } fn generate_psu_agent_proto() { @@ -100,4 +103,80 @@ END"#, version_rc } + + pub(super) fn embed_devolutions_agent_mc() { + use std::path::PathBuf; + use std::process::Command; + + let profile = env::var("PROFILE").unwrap_or_default(); + if !matches!(profile.as_str(), "release" | "production") { + return; + } + + let mc_exe = find_mc().unwrap_or_else(|| { + panic!( + "mc.exe is required to embed the Devolutions Agent Event Log catalog; \ + use a Visual Studio developer shell or set WindowsSdkVerBinPath or WindowsSdkDir" + ) + }); + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let catalog = manifest_dir.join("devolutions-agent.mc"); + println!("cargo:rerun-if-changed={}", catalog.display()); + + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")); + let status = Command::new(mc_exe) + .current_dir(&out_dir) + .args(["-um", "-h", ".", "-r", "."]) + .arg(catalog.canonicalize().expect("canonicalize Agent message catalog")) + .status() + .expect("run mc.exe"); + assert!(status.success(), "mc.exe failed with status {status}"); + + let resource = out_dir.join("devolutions-agent.rc"); + assert!(resource.is_file(), "mc.exe did not generate {}", resource.display()); + embed_resource::compile(resource, embed_resource::NONE) + .manifest_required() + .expect("BUG: failed to embed devolutions-agent.rc"); + } + + fn find_mc() -> Option { + if let Ok(sdk_bin) = env::var("WindowsSdkVerBinPath") { + let sdk_bin = std::path::Path::new(&sdk_bin); + for candidate in [sdk_bin.join("mc.exe"), sdk_bin.join("x64").join("mc.exe")] { + if candidate.is_file() { + return Some(candidate); + } + } + } + + let bin_dir = std::path::PathBuf::from(env::var_os("WindowsSdkDir")?).join("bin"); + let direct = bin_dir.join("x64").join("mc.exe"); + if direct.is_file() { + return Some(direct); + } + + let mut versions: Vec<_> = fs::read_dir(bin_dir) + .ok()? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .collect(); + versions.sort_by_key(|path| { + std::cmp::Reverse( + path.file_name() + .and_then(|name| name.to_str()) + .and_then(|name| { + name.split('.') + .map(str::parse::) + .collect::, _>>() + .ok() + }) + .unwrap_or_default(), + ) + }); + versions + .into_iter() + .map(|directory| directory.join("x64").join("mc.exe")) + .find(|path| path.is_file()) + } } diff --git a/devolutions-agent/devolutions-agent.mc b/devolutions-agent/devolutions-agent.mc new file mode 100644 index 000000000..179f4f336 --- /dev/null +++ b/devolutions-agent/devolutions-agent.mc @@ -0,0 +1,554 @@ +; Devolutions Agent Windows Event Log message definitions. + +MessageIdTypedef=DWORD + +SeverityNames=( + Success=0x0:STATUS_SEVERITY_SUCCESS + Informational=0x1:STATUS_SEVERITY_INFORMATIONAL + Warning=0x2:STATUS_SEVERITY_WARNING + Error=0x3:STATUS_SEVERITY_ERROR +) + +FacilityNames=( + Application=0x0:FACILITY_APPLICATION +) + +LanguageNames=( + English=0x409:MSG00409 + French=0x40c:MSG0040c + German=0x407:MSG00407 +) + +; 1000-1099 Service / Lifecycle + +MessageId=1000 +SymbolicName=SERVICE_STARTED +Language=English +Service started. Context=%1 Version=%2 +. +Language=French +Service démarré. Contexte=%1 Version=%2 +. +Language=German +Dienst gestartet. Kontext=%1 Version=%2 +. + +MessageId=1001 +SymbolicName=SERVICE_STOPPING +Language=English +Service stopping. Context=%1 Reason=%2 +. +Language=French +Arrêt du service. Contexte=%1 Raison=%2 +. +Language=German +Dienst wird gestoppt. Kontext=%1 Grund=%2 +. + +MessageId=1010 +SymbolicName=CONFIG_INVALID +Language=English +Configuration invalid. Context=%1 Path=%2 Error=%3 Reason=%4 +. +Language=French +Configuration invalide. Contexte=%1 Chemin=%2 Erreur=%3 Raison=%4 +. +Language=German +Ungültige Konfiguration. Kontext=%1 Pfad=%2 Fehler=%3 Grund=%4 +. + +MessageId=1020 +SymbolicName=START_FAILED +Language=English +Start failed. Context=%1 Cause=%2 Error=%3 +. +Language=French +Échec du démarrage. Contexte=%1 Cause=%2 Erreur=%3 +. +Language=German +Start fehlgeschlagen. Kontext=%1 Ursache=%2 Fehler=%3 +. + +MessageId=1030 +SymbolicName=BOOT_STACKTRACE_WRITTEN +Language=English +Boot stacktrace written. Context=%1 Path=%2 +. +Language=French +Trace d’amorçage écrite. Contexte=%1 Chemin=%2 +. +Language=German +Boot-Stacktrace geschrieben. Kontext=%1 Pfad=%2 +. + +; 2000-2099 Listeners and Networking + +MessageId=2000 +SymbolicName=LISTENER_STARTED +Language=English +Listener started. Context=%1 Address=%2 Proto=%3 +. +Language=French +Écouteur démarré. Contexte=%1 Adresse=%2 Protocole=%3 +. +Language=German +Listener gestartet. Kontext=%1 Adresse=%2 Protokoll=%3 +. + +MessageId=2001 +SymbolicName=LISTENER_BIND_FAILED +Language=English +Listener bind failed. Context=%1 Address=%2 Error=%3 +. +Language=French +Échec de l’attachement de l’écouteur. Contexte=%1 Adresse=%2 Erreur=%3 +. +Language=German +Listener-Bind fehlgeschlagen. Kontext=%1 Adresse=%2 Fehler=%3 +. + +MessageId=2002 +SymbolicName=LISTENER_STOPPED +Language=English +Listener stopped. Context=%1 Address=%2 Reason=%3 +. +Language=French +Écouteur arrêté. Contexte=%1 Adresse=%2 Raison=%3 +. +Language=German +Listener gestoppt. Kontext=%1 Adresse=%2 Grund=%3 +. + +; 3000-3099 TLS / Certificates + +MessageId=3000 +SymbolicName=TLS_CONFIGURED +Language=English +TLS configured. Context=%1 Source=%2 +. +Language=French +TLS configuré. Contexte=%1 Source=%2 +. +Language=German +TLS konfiguriert. Kontext=%1 Quelle=%2 +. + +MessageId=3001 +SymbolicName=TLS_VERIFY_STRICT_DISABLED +Language=English +TLS strict verification disabled. Context=%1 Mode=%2 +. +Language=French +Vérification stricte TLS désactivée. Contexte=%1 Mode=%2 +. +Language=German +Strikte TLS-Überprüfung deaktiviert. Kontext=%1 Modus=%2 +. + +MessageId=3002 +SymbolicName=TLS_CERTIFICATE_REJECTED +Language=English +Certificate rejected. Context=%1 Subject=%2 Reason=%3 +. +Language=French +Certificat rejeté. Contexte=%1 Sujet=%2 Raison=%3 +. +Language=German +Zertifikat abgelehnt. Kontext=%1 Betreff=%2 Grund=%3 +. + +MessageId=3003 +SymbolicName=SYSTEM_CERT_SELECTED +Language=English +System certificate selected. Context=%1 Thumbprint=%2 Subject=%3 +. +Language=French +Certificat système sélectionné. Contexte=%1 Empreinte=%2 Sujet=%3 +. +Language=German +Systemzertifikat ausgewählt. Kontext=%1 Fingerabdruck=%2 Betreff=%3 +. + +MessageId=3004 +SymbolicName=TLS_KEY_LOAD_FAILED +Language=English +TLS key/cert load failed. Context=%1 Path=%2 Error=%3 Reason=%4 +. +Language=French +Échec du chargement de la clé/cert TLS. Contexte=%1 Chemin=%2 Erreur=%3 Raison=%4 +. +Language=German +TLS-Schlüssel/Zertifikat konnte nicht geladen werden. Kontext=%1 Pfad=%2 Fehler=%3 Grund=%4 +. + +MessageId=3005 +SymbolicName=TLS_CERTIFICATE_NAME_MISMATCH +Language=English +TLS certificate name mismatch. Context=%1 Hostname=%2 Subject=%3 Reason=%4 +. +Language=French +Nom du certificat TLS non concordant. Contexte=%1 Hôte=%2 Sujet=%3 Raison=%4 +. +Language=German +TLS-Zertifikat-Namen stimmt nicht überein. Kontext=%1 Hostname=%2 Betreff=%3 Grund=%4 +. + +MessageId=3006 +SymbolicName=TLS_NO_SUITABLE_CERTIFICATE +Language=English +No suitable certificate found. Context=%1 Error=%2 Issues=%3 +. +Language=French +Aucun certificat approprié trouvé. Contexte=%1 Erreur=%2 Problèmes=%3 +. +Language=German +Kein geeignetes Zertifikat gefunden. Kontext=%1 Fehler=%2 Probleme=%3 +. + +; 4000-4099 Sessions, Tokens and Recording + +MessageId=4000 +SymbolicName=SESSION_OPENED +Language=English +Session opened. Context=%1 Protocol=%2 Client=%3 Target=%4 TokenId=%5 +. +Language=French +Session ouverte. Contexte=%1 Protocole=%2 Client=%3 Cible=%4 Jeton=%5 +. +Language=German +Sitzung geöffnet. Kontext=%1 Protokoll=%2 Client=%3 Ziel=%4 Token=%5 +. + +MessageId=4001 +SymbolicName=SESSION_CLOSED +Language=English +Session closed. Context=%1 DurationMs=%2 BytesTx=%3 BytesRx=%4 Outcome=%5 +. +Language=French +Session fermée. Contexte=%1 DuréeMs=%2 OctetsTx=%3 OctetsRx=%4 Résultat=%5 +. +Language=German +Sitzung geschlossen. Kontext=%1 DauerMs=%2 BytesTx=%3 BytesRx=%4 Ergebnis=%5 +. + +MessageId=4010 +SymbolicName=TOKEN_PROVISIONED +Language=English +Token provisioned. Context=%1 TokenId=%2 +. +Language=French +Jeton provisionné. Contexte=%1 Jeton=%2 +. +Language=German +Token bereitgestellt. Kontext=%1 Token=%2 +. + +MessageId=4011 +SymbolicName=TOKEN_REUSED +Language=English +Token reused. Context=%1 TokenId=%2 ReuseCount=%3 +. +Language=French +Jeton réutilisé. Contexte=%1 Jeton=%2 Réutilisations=%3 +. +Language=German +Token wiederverwendet. Kontext=%1 Token=%2 Anzahl=%3 +. + +MessageId=4012 +SymbolicName=TOKEN_REUSE_LIMIT_EXCEEDED +Language=English +Token reuse limit exceeded. Context=%1 TokenId=%2 Limit=%3 Reason=%4 +. +Language=French +Limite de réutilisation du jeton dépassée. Contexte=%1 Jeton=%2 Limite=%3 Raison=%4 +. +Language=German +Token-Wiederverwendungsgrenze überschritten. Kontext=%1 Token=%2 Limit=%3 Grund=%4 +. + +MessageId=4030 +SymbolicName=RECORDING_STARTED +Language=English +Recording started. Context=%1 Destination=%2 +. +Language=French +Enregistrement démarré. Contexte=%1 Destination=%2 +. +Language=German +Aufnahme gestartet. Kontext=%1 Ziel=%2 +. + +MessageId=4031 +SymbolicName=RECORDING_STOPPED +Language=English +Recording stopped. Context=%1 Bytes=%2 Files=%3 +. +Language=French +Enregistrement arrêté. Contexte=%1 Octets=%2 Fichiers=%3 +. +Language=German +Aufnahme gestoppt. Kontext=%1 Bytes=%2 Dateien=%3 +. + +MessageId=4032 +SymbolicName=RECORDING_ERROR +Language=English +Recording error. Context=%1 Path=%2 Error=%3 +. +Language=French +Erreur d’enregistrement. Contexte=%1 Chemin=%2 Erreur=%3 +. +Language=German +Aufnahmefehler. Kontext=%1 Pfad=%2 Fehler=%3 +. + +; 5000-5099 Authentication / Authorization + +MessageId=5001 +SymbolicName=JWT_REJECTED +Language=English +JWT rejected. Context=%1 ReasonCode=%2 Reason=%3 +. +Language=French +JWT rejeté. Contexte=%1 CodeRaison=%2 Raison=%3 +. +Language=German +JWT abgelehnt. Kontext=%1 GrundCode=%2 Grund=%3 +. + +MessageId=5002 +SymbolicName=JWT_ANOMALY +Language=English +JWT anomaly. Context=%1 Issuer=%2 Audience=%3 Kid=%4 Kind=%5 Detail=%6 +. +Language=French +Anomalie JWT. Contexte=%1 Émetteur=%2 Audience=%3 Kid=%4 Type=%5 Détail=%6 +. +Language=German +JWT-Anomalie. Kontext=%1 Aussteller=%2 Audience=%3 Kid=%4 Typ=%5 Detail=%6 +. + +MessageId=5010 +SymbolicName=AUTHORIZATION_DENIED +Language=English +Authorization denied. Context=%1 Subject=%2 Action=%3 Resource=%4 Rule=%5 Reason=%6 +. +Language=French +Autorisation refusée. Contexte=%1 Sujet=%2 Action=%3 Ressource=%4 Règle=%5 Raison=%6 +. +Language=German +Autorisierung verweigert. Kontext=%1 Subjekt=%2 Aktion=%3 Ressource=%4 Regel=%5 Grund=%6 +. + +MessageId=5090 +SymbolicName=AUTH_SUMMARY +Language=English +Auth summary. Context=%1 IntervalSec=%2 JwtOk=%3 JwtRejected=%4 Denied=%5 ByReason=%6 +. +Language=French +Résumé d’auth. Contexte=%1 IntervalSec=%2 JwtOk=%3 JwtRejeté=%4 Refusé=%5 ParRaison=%6 +. +Language=German +Auth-Zusammenfassung. Kontext=%1 IntervallSek=%2 JwtOk=%3 JwtAbgelehnt=%4 Verweigert=%5 NachGrund=%6 +. + +; 6000-6099 Agent Integration + +MessageId=6000 +SymbolicName=USER_SESSION_PROCESS_STARTED +Language=English +User session process started. Context=%1 SessionId=%2 Kind=%3 Exe=%4 +. +Language=French +Processus de session utilisateur démarré. Contexte=%1 SessionId=%2 Type=%3 Exe=%4 +. +Language=German +Benutzersitzungsprozess gestartet. Kontext=%1 SessionId=%2 Typ=%3 Exe=%4 +. + +MessageId=6001 +SymbolicName=USER_SESSION_PROCESS_TERMINATED +Language=English +User session process terminated. Context=%1 SessionId=%2 ExitCode=%3 By=%4 +. +Language=French +Processus de session utilisateur terminé. Contexte=%1 SessionId=%2 CodeSortie=%3 Par=%4 +. +Language=German +Benutzersitzungsprozess beendet. Kontext=%1 SessionId=%2 ExitCode=%3 Durch=%4 +. + +MessageId=6010 +SymbolicName=UPDATER_TASK_ENABLED +Language=English +Updater task enabled. Context=%1 +. +Language=French +Tâche de mise à jour activée. Contexte=%1 +. +Language=German +Update-Aufgabe aktiviert. Kontext=%1 +. + +MessageId=6011 +SymbolicName=UPDATER_ERROR +Language=English +Updater error. Context=%1 Step=%2 Error=%3 +. +Language=French +Erreur de mise à jour. Contexte=%1 Étape=%2 Erreur=%3 +. +Language=German +Update-Fehler. Kontext=%1 Schritt=%2 Fehler=%3 +. + +MessageId=6020 +SymbolicName=PEDM_ENABLED +Language=English +PEDM enabled. Context=%1 +. +Language=French +PEDM activé. Contexte=%1 +. +Language=German +PEDM aktiviert. Kontext=%1 +. + +; 7000-7099 Health + +MessageId=7010 +SymbolicName=RECORDING_STORAGE_LOW +Language=English +Recording storage low. Context=%1 RemainingBytes=%2 ThresholdBytes=%3 +. +Language=French +Espace d’enregistrement faible. Contexte=%1 OctetsRestants=%2 Seuil=%3 +. +Language=German +Aufnahmespeicher niedrig. Kontext=%1 VerbleibendeBytes=%2 Schwelle=%3 +. + +; 8000-8099 Package Broker / Policy Management + +MessageId=8000 +SymbolicName=POLICY_WRITE_ATTEMPTED +Language=English +Policy management write attempted. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 +. +Language=French +Tentative d’écriture de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 +. +Language=German +Richtlinien-Schreibvorgang versucht. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 +. + +MessageId=8001 +SymbolicName=POLICY_WRITE_DENIED +Language=English +Policy management write denied. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Reason=%6 +. +Language=French +Écriture de politique refusée. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Raison=%6 +. +Language=German +Richtlinien-Schreibvorgang verweigert. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Grund=%6 +. + +MessageId=8002 +SymbolicName=POLICY_CREATE_FAILED +Language=English +Policy creation failed. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Operation=%6 Outcome=%7 Reason=%8 +. +Language=French +Échec de la création de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Opération=%6 Résultat=%7 Raison=%8 +. +Language=German +Richtlinienerstellung fehlgeschlagen. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Vorgang=%6 Ergebnis=%7 Grund=%8 +. + +MessageId=8003 +SymbolicName=POLICY_CREATE_SUCCEEDED +Language=English +Policy creation succeeded. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 Operation=%10 Outcome=%11 +. +Language=French +Création de politique réussie. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 Opération=%10 Résultat=%11 +. +Language=German +Richtlinie erfolgreich erstellt. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 Vorgang=%10 Ergebnis=%11 +. + +MessageId=8004 +SymbolicName=POLICY_CHANGE_FAILED +Language=English +Policy change failed. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Operation=%6 Outcome=%7 Reason=%8 +. +Language=French +Échec de la modification de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Opération=%6 Résultat=%7 Raison=%8 +. +Language=German +Richtlinienänderung fehlgeschlagen. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Vorgang=%6 Ergebnis=%7 Grund=%8 +. + +MessageId=8005 +SymbolicName=POLICY_CHANGE_SUCCEEDED +Language=English +Policy change succeeded. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 Operation=%10 Outcome=%11 +. +Language=French +Modification de politique réussie. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 Opération=%10 Résultat=%11 +. +Language=German +Richtlinie erfolgreich geändert. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 Vorgang=%10 Ergebnis=%11 +. + +MessageId=8010 +SymbolicName=POLICY_EXTERNAL_CHANGE_APPLIED +Language=English +External policy change applied. Context=%1 Path=%2 NewId=%3 NewRevision=%4 +. +Language=French +Modification externe de la politique appliquée. Contexte=%1 Chemin=%2 NouvelId=%3 NouvelleRévision=%4 +. +Language=German +Externe Richtlinienänderung angewendet. Kontext=%1 Pfad=%2 NeueId=%3 NeueRevision=%4 +. + +MessageId=8011 +SymbolicName=POLICY_EXTERNAL_CHANGE_REJECTED +Language=English +External policy change rejected. Context=%1 Path=%2 Reason=%3 +. +Language=French +Modification externe de la politique rejetée. Contexte=%1 Chemin=%2 Raison=%3 +. +Language=German +Externe Richtlinienänderung abgelehnt. Kontext=%1 Pfad=%2 Grund=%3 +. + +; 9000-9099 Diagnostics + +MessageId=9001 +SymbolicName=DEBUG_OPTIONS_ENABLED +Language=English +Debug options enabled. Context=%1 Options=%2 +. +Language=French +Options de débogage activées. Contexte=%1 Options=%2 +. +Language=German +Debug-Optionen aktiviert. Kontext=%1 Optionen=%2 +. + +MessageId=9002 +SymbolicName=XMF_NOT_FOUND +Language=English +XMF not found. Context=%1 Path=%2 Error=%3 +. +Language=French +XMF introuvable. Contexte=%1 Chemin=%2 Erreur=%3 +. +Language=German +XMF nicht gefunden. Kontext=%1 Pfad=%2 Fehler=%3 +. diff --git a/devolutions-agent/src/service.rs b/devolutions-agent/src/service.rs index 6739ea391..cd3f84b1d 100644 --- a/devolutions-agent/src/service.rs +++ b/devolutions-agent/src/service.rs @@ -21,10 +21,18 @@ use now_package_broker::pipe::DEFAULT_PIPE_NAME; use now_package_broker::task::{BrokerTask, BrokerTaskConfig}; use tokio::runtime::{self, Runtime}; use tokio::sync::mpsc; +#[cfg(windows)] +use windows_registry::{Key, LOCAL_MACHINE}; pub(crate) const SERVICE_NAME: &str = "devolutions-agent"; pub(crate) const DISPLAY_NAME: &str = "Devolutions Agent"; pub(crate) const DESCRIPTION: &str = "Devolutions Agent service"; +#[cfg(windows)] +const POLICY_CONSENT_DISCOVERY_KEY: &str = r"SOFTWARE\Devolutions\Agent\PolicyConsentHelper"; +#[cfg(windows)] +const POLICY_CONSENT_BROKER_PIPE_NAME_VALUE: &str = "BrokerPipeName"; +#[cfg(all(windows, target_pointer_width = "64"))] +const KEY_WOW64_32KEY: u32 = 0x0200; struct TasksCtx { /// Spawned service tasks @@ -226,12 +234,19 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { ); } + let pipe_name = conf + .package_broker + .pipe_name + .clone() + .unwrap_or_else(|| DEFAULT_PIPE_NAME.to_owned()); + if let Err(error) = publish_policy_consent_broker_pipe_name(&pipe_name) { + warn!( + error = format!("{error:#}"), + "Policy consent helper cannot discover the configured broker pipe" + ); + } let broker_config = BrokerTaskConfig { - pipe_name: conf - .package_broker - .pipe_name - .clone() - .unwrap_or_else(|| DEFAULT_PIPE_NAME.to_owned()), + pipe_name, policy_path: conf.package_broker.policy_path.clone(), // The bypass only takes effect in builds with the development-only // `dev-skip-broker-signature` cargo feature, never in shipped builds. @@ -271,3 +286,61 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { service_event_tx, }) } + +#[cfg(windows)] +fn publish_policy_consent_broker_pipe_name(pipe_name: &str) -> anyhow::Result<()> { + let key = LOCAL_MACHINE + .options() + .read() + .write() + .open(POLICY_CONSENT_DISCOVERY_KEY) + .context("open policy consent helper discovery key")?; + publish_policy_consent_broker_pipe_name_to(&key, pipe_name)?; + + // A 32-bit UniGetUI process reads HKLM\Software through WOW6432Node, while the + // 64-bit Agent service naturally opens the native view. Keep configured pipe + // discovery synchronized with the MSI's dual-view contract. + #[cfg(target_pointer_width = "64")] + { + let wow64_key = LOCAL_MACHINE + .options() + .read() + .write() + .access(KEY_WOW64_32KEY) + .open(POLICY_CONSENT_DISCOVERY_KEY) + .context("open 32-bit policy consent helper discovery key")?; + publish_policy_consent_broker_pipe_name_to(&wow64_key, pipe_name) + .context("publish configured policy consent helper broker pipe to 32-bit registry view")?; + } + + Ok(()) +} + +#[cfg(windows)] +fn publish_policy_consent_broker_pipe_name_to(key: &Key, pipe_name: &str) -> anyhow::Result<()> { + key.set_string(POLICY_CONSENT_BROKER_PIPE_NAME_VALUE, pipe_name) + .context("publish configured policy consent helper broker pipe") +} + +#[cfg(all(test, windows))] +mod tests { + use windows_registry::CURRENT_USER; + + use super::*; + + #[test] + fn configured_broker_pipe_is_published_to_writable_discovery_key() { + let path = format!(r"Software\Devolutions\Agent\Tests\{}", uuid::Uuid::new_v4().as_simple()); + let key = CURRENT_USER.create(&path).expect("create temporary discovery key"); + let pipe_name = r"\\.\pipe\custom-broker"; + + publish_policy_consent_broker_pipe_name_to(&key, pipe_name).expect("publish configured broker pipe"); + + assert_eq!( + key.get_string(POLICY_CONSENT_BROKER_PIPE_NAME_VALUE) + .expect("read published broker pipe"), + pipe_name + ); + CURRENT_USER.remove_tree(&path).expect("remove temporary discovery key"); + } +} diff --git a/devolutions-gateway/build.rs b/devolutions-gateway/build.rs index d242d5610..93b484b13 100644 --- a/devolutions-gateway/build.rs +++ b/devolutions-gateway/build.rs @@ -94,20 +94,18 @@ END"#, use std::path::PathBuf; use std::process::Command; - // --- gate: only release builds ------------------------------------- + // --- gate: only release and production profiles -------------------- let profile = env::var("PROFILE").unwrap_or_default(); - if profile != "release" { + if !matches!(profile.as_str(), "release" | "production") { return; } - // --- gate: ignore with a warning when mc is not found -------------- - let mc_exe_path = match find_mc() { - Some(path) => path, - None => { - println!("cargo:warning=Did not find mc.exe"); - return; - } - }; + let mc_exe_path = find_mc().unwrap_or_else(|| { + panic!( + "mc.exe is required to embed the Devolutions Gateway Event Log catalog; \ + use a Visual Studio developer shell or set WindowsSdkVerBinPath or WindowsSdkDir" + ) + }); // --- inputs/paths --------------------------------------------------- let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); @@ -163,20 +161,42 @@ END"#, fn find_mc() -> Option { if let Ok(sdk_bin) = env::var("WindowsSdkVerBinPath") { - let p = std::path::Path::new(&sdk_bin).join("mc.exe"); - if p.exists() { - return Some(p); + let sdk_bin = std::path::Path::new(&sdk_bin); + for candidate in [sdk_bin.join("mc.exe"), sdk_bin.join("x64").join("mc.exe")] { + if candidate.is_file() { + return Some(candidate); + } } } - if let Ok(sdk_dir) = env::var("WindowsSdkDir") { - // e.g. C:\Program Files (x86)\Windows Kits\10\ - let candidate = std::path::Path::new(&sdk_dir).join("bin").join("x64").join("mc.exe"); - if candidate.exists() { - return Some(candidate); - } + let bin_dir = std::path::PathBuf::from(env::var_os("WindowsSdkDir")?).join("bin"); + let direct = bin_dir.join("x64").join("mc.exe"); + if direct.is_file() { + return Some(direct); } - None + let mut versions: Vec<_> = fs::read_dir(bin_dir) + .ok()? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .collect(); + versions.sort_by_key(|path| { + std::cmp::Reverse( + path.file_name() + .and_then(|name| name.to_str()) + .and_then(|name| { + name.split('.') + .map(str::parse::) + .collect::, _>>() + .ok() + }) + .unwrap_or_default(), + ) + }); + versions + .into_iter() + .map(|directory| directory.join("x64").join("mc.exe")) + .find(|path| path.is_file()) } } diff --git a/devolutions-gateway/devolutions-gateway.mc b/devolutions-gateway/devolutions-gateway.mc index 4a9b99f8a..470c1de05 100644 --- a/devolutions-gateway/devolutions-gateway.mc +++ b/devolutions-gateway/devolutions-gateway.mc @@ -1,4 +1,4 @@ -; ---------------------------------------------------------------------- +; ---------------------------------------------------------------------- ; Devolutions Gateway - Windows Event Log message definitions (.mc) ; English (0x409), French (0x40c), German (0x407) ; ---------------------------------------------------------------------- @@ -30,8 +30,10 @@ MessageId=1000 SymbolicName=SERVICE_STARTED Language=English Service started. Context=%1 Version=%2 +. Language=French Service démarré. Contexte=%1 Version=%2 +. Language=German Dienst gestartet. Kontext=%1 Version=%2 . @@ -40,8 +42,10 @@ MessageId=1001 SymbolicName=SERVICE_STOPPING Language=English Service stopping. Context=%1 Reason=%2 +. Language=French Arrêt du service. Contexte=%1 Raison=%2 +. Language=German Dienst wird gestoppt. Kontext=%1 Grund=%2 . @@ -50,8 +54,10 @@ MessageId=1010 SymbolicName=CONFIG_INVALID Language=English Configuration invalid. Context=%1 Path=%2 Error=%3 Reason=%4 +. Language=French Configuration invalide. Contexte=%1 Chemin=%2 Erreur=%3 Raison=%4 +. Language=German Ungültige Konfiguration. Kontext=%1 Pfad=%2 Fehler=%3 Grund=%4 . @@ -60,8 +66,10 @@ MessageId=1020 SymbolicName=START_FAILED Language=English Start failed. Context=%1 Cause=%2 Error=%3 +. Language=French Échec du démarrage. Contexte=%1 Cause=%2 Erreur=%3 +. Language=German Start fehlgeschlagen. Kontext=%1 Ursache=%2 Fehler=%3 . @@ -70,8 +78,10 @@ MessageId=1030 SymbolicName=BOOT_STACKTRACE_WRITTEN Language=English Boot stacktrace written. Context=%1 Path=%2 +. Language=French Trace d’amorçage écrite. Contexte=%1 Chemin=%2 +. Language=German Boot-Stacktrace geschrieben. Kontext=%1 Pfad=%2 . @@ -84,8 +94,10 @@ MessageId=2000 SymbolicName=LISTENER_STARTED Language=English Listener started. Context=%1 Address=%2 Proto=%3 +. Language=French Écouteur démarré. Contexte=%1 Adresse=%2 Protocole=%3 +. Language=German Listener gestartet. Kontext=%1 Adresse=%2 Protokoll=%3 . @@ -94,8 +106,10 @@ MessageId=2001 SymbolicName=LISTENER_BIND_FAILED Language=English Listener bind failed. Context=%1 Address=%2 Error=%3 +. Language=French Échec de l’attachement de l’écouteur. Contexte=%1 Adresse=%2 Erreur=%3 +. Language=German Listener-Bind fehlgeschlagen. Kontext=%1 Adresse=%2 Fehler=%3 . @@ -104,8 +118,10 @@ MessageId=2002 SymbolicName=LISTENER_STOPPED Language=English Listener stopped. Context=%1 Address=%2 Reason=%3 +. Language=French Écouteur arrêté. Contexte=%1 Adresse=%2 Raison=%3 +. Language=German Listener gestoppt. Kontext=%1 Adresse=%2 Grund=%3 . @@ -118,8 +134,10 @@ MessageId=3000 SymbolicName=TLS_CONFIGURED Language=English TLS configured. Context=%1 Source=%2 +. Language=French TLS configuré. Contexte=%1 Source=%2 +. Language=German TLS konfiguriert. Kontext=%1 Quelle=%2 . @@ -128,8 +146,10 @@ MessageId=3001 SymbolicName=TLS_VERIFY_STRICT_DISABLED Language=English TLS strict verification disabled. Context=%1 Mode=%2 +. Language=French Vérification stricte TLS désactivée. Contexte=%1 Mode=%2 +. Language=German Strikte TLS-Überprüfung deaktiviert. Kontext=%1 Modus=%2 . @@ -138,8 +158,10 @@ MessageId=3002 SymbolicName=TLS_CERTIFICATE_REJECTED Language=English Certificate rejected. Context=%1 Subject=%2 Reason=%3 +. Language=French Certificat rejeté. Contexte=%1 Sujet=%2 Raison=%3 +. Language=German Zertifikat abgelehnt. Kontext=%1 Betreff=%2 Grund=%3 . @@ -148,8 +170,10 @@ MessageId=3003 SymbolicName=SYSTEM_CERT_SELECTED Language=English System certificate selected. Context=%1 Thumbprint=%2 Subject=%3 +. Language=French Certificat système sélectionné. Contexte=%1 Empreinte=%2 Sujet=%3 +. Language=German Systemzertifikat ausgewählt. Kontext=%1 Fingerabdruck=%2 Betreff=%3 . @@ -158,8 +182,10 @@ MessageId=3004 SymbolicName=TLS_KEY_LOAD_FAILED Language=English TLS key/cert load failed. Context=%1 Path=%2 Error=%3 Reason=%4 +. Language=French Échec du chargement de la clé/cert TLS. Contexte=%1 Chemin=%2 Erreur=%3 Raison=%4 +. Language=German TLS-Schlüssel/Zertifikat konnte nicht geladen werden. Kontext=%1 Pfad=%2 Fehler=%3 Grund=%4 . @@ -168,8 +194,10 @@ MessageId=3005 SymbolicName=TLS_CERTIFICATE_NAME_MISMATCH Language=English TLS certificate name mismatch. Context=%1 Hostname=%2 Subject=%3 Reason=%4 +. Language=French Nom du certificat TLS non concordant. Contexte=%1 Hôte=%2 Sujet=%3 Raison=%4 +. Language=German TLS-Zertifikat-Namen stimmt nicht überein. Kontext=%1 Hostname=%2 Betreff=%3 Grund=%4 . @@ -178,8 +206,10 @@ MessageId=3006 SymbolicName=TLS_NO_SUITABLE_CERTIFICATE Language=English No suitable certificate found. Context=%1 Error=%2 Issues=%3 +. Language=French Aucun certificat approprié trouvé. Contexte=%1 Erreur=%2 Problèmes=%3 +. Language=German Kein geeignetes Zertifikat gefunden. Kontext=%1 Fehler=%2 Probleme=%3 . @@ -192,8 +222,10 @@ MessageId=4000 SymbolicName=SESSION_OPENED Language=English Session opened. Context=%1 Protocol=%2 Client=%3 Target=%4 TokenId=%5 +. Language=French Session ouverte. Contexte=%1 Protocole=%2 Client=%3 Cible=%4 Jeton=%5 +. Language=German Sitzung geöffnet. Kontext=%1 Protokoll=%2 Client=%3 Ziel=%4 Token=%5 . @@ -202,8 +234,10 @@ MessageId=4001 SymbolicName=SESSION_CLOSED Language=English Session closed. Context=%1 DurationMs=%2 BytesTx=%3 BytesRx=%4 Outcome=%5 +. Language=French Session fermée. Contexte=%1 DuréeMs=%2 OctetsTx=%3 OctetsRx=%4 Résultat=%5 +. Language=German Sitzung geschlossen. Kontext=%1 DauerMs=%2 BytesTx=%3 BytesRx=%4 Ergebnis=%5 . @@ -212,8 +246,10 @@ MessageId=4010 SymbolicName=TOKEN_PROVISIONED Language=English Token provisioned. Context=%1 TokenId=%2 +. Language=French Jeton provisionné. Contexte=%1 Jeton=%2 +. Language=German Token bereitgestellt. Kontext=%1 Token=%2 . @@ -222,8 +258,10 @@ MessageId=4011 SymbolicName=TOKEN_REUSED Language=English Token reused. Context=%1 TokenId=%2 ReuseCount=%3 +. Language=French Jeton réutilisé. Contexte=%1 Jeton=%2 Réutilisations=%3 +. Language=German Token wiederverwendet. Kontext=%1 Token=%2 Anzahl=%3 . @@ -232,8 +270,10 @@ MessageId=4012 SymbolicName=TOKEN_REUSE_LIMIT_EXCEEDED Language=English Token reuse limit exceeded. Context=%1 TokenId=%2 Limit=%3 Reason=%4 +. Language=French Limite de réutilisation du jeton dépassée. Contexte=%1 Jeton=%2 Limite=%3 Raison=%4 +. Language=German Token-Wiederverwendungsgrenze überschritten. Kontext=%1 Token=%2 Limit=%3 Grund=%4 . @@ -242,8 +282,10 @@ MessageId=4030 SymbolicName=RECORDING_STARTED Language=English Recording started. Context=%1 Destination=%2 +. Language=French Enregistrement démarré. Contexte=%1 Destination=%2 +. Language=German Aufnahme gestartet. Kontext=%1 Ziel=%2 . @@ -252,8 +294,10 @@ MessageId=4031 SymbolicName=RECORDING_STOPPED Language=English Recording stopped. Context=%1 Bytes=%2 Files=%3 +. Language=French Enregistrement arrêté. Contexte=%1 Octets=%2 Fichiers=%3 +. Language=German Aufnahme gestoppt. Kontext=%1 Bytes=%2 Dateien=%3 . @@ -262,8 +306,10 @@ MessageId=4032 SymbolicName=RECORDING_ERROR Language=English Recording error. Context=%1 Path=%2 Error=%3 +. Language=French Erreur d’enregistrement. Contexte=%1 Chemin=%2 Erreur=%3 +. Language=German Aufnahmefehler. Kontext=%1 Pfad=%2 Fehler=%3 . @@ -276,8 +322,10 @@ MessageId=5001 SymbolicName=JWT_REJECTED Language=English JWT rejected. Context=%1 ReasonCode=%2 Reason=%3 +. Language=French JWT rejeté. Contexte=%1 CodeRaison=%2 Raison=%3 +. Language=German JWT abgelehnt. Kontext=%1 GrundCode=%2 Grund=%3 . @@ -286,8 +334,10 @@ MessageId=5002 SymbolicName=JWT_ANOMALY Language=English JWT anomaly. Context=%1 Issuer=%2 Audience=%3 Kid=%4 Kind=%5 Detail=%6 +. Language=French Anomalie JWT. Contexte=%1 Émetteur=%2 Audience=%3 Kid=%4 Type=%5 Détail=%6 +. Language=German JWT-Anomalie. Kontext=%1 Aussteller=%2 Audience=%3 Kid=%4 Typ=%5 Detail=%6 . @@ -296,8 +346,10 @@ MessageId=5010 SymbolicName=AUTHORIZATION_DENIED Language=English Authorization denied. Context=%1 Subject=%2 Action=%3 Resource=%4 Rule=%5 Reason=%6 +. Language=French Autorisation refusée. Contexte=%1 Sujet=%2 Action=%3 Ressource=%4 Règle=%5 Raison=%6 +. Language=German Autorisierung verweigert. Kontext=%1 Subjekt=%2 Aktion=%3 Ressource=%4 Regel=%5 Grund=%6 . @@ -306,8 +358,10 @@ MessageId=5090 SymbolicName=AUTH_SUMMARY Language=English Auth summary. Context=%1 IntervalSec=%2 JwtOk=%3 JwtRejected=%4 Denied=%5 ByReason=%6 +. Language=French Résumé d’auth. Contexte=%1 IntervalSec=%2 JwtOk=%3 JwtRejeté=%4 Refusé=%5 ParRaison=%6 +. Language=German Auth-Zusammenfassung. Kontext=%1 IntervallSek=%2 JwtOk=%3 JwtAbgelehnt=%4 Verweigert=%5 NachGrund=%6 . @@ -320,8 +374,10 @@ MessageId=6000 SymbolicName=USER_SESSION_PROCESS_STARTED Language=English User session process started. Context=%1 SessionId=%2 Kind=%3 Exe=%4 +. Language=French Processus de session utilisateur démarré. Contexte=%1 SessionId=%2 Type=%3 Exe=%4 +. Language=German Benutzersitzungsprozess gestartet. Kontext=%1 SessionId=%2 Typ=%3 Exe=%4 . @@ -330,8 +386,10 @@ MessageId=6001 SymbolicName=USER_SESSION_PROCESS_TERMINATED Language=English User session process terminated. Context=%1 SessionId=%2 ExitCode=%3 By=%4 +. Language=French Processus de session utilisateur terminé. Contexte=%1 SessionId=%2 CodeSortie=%3 Par=%4 +. Language=German Benutzersitzungsprozess beendet. Kontext=%1 SessionId=%2 ExitCode=%3 Durch=%4 . @@ -340,8 +398,10 @@ MessageId=6010 SymbolicName=UPDATER_TASK_ENABLED Language=English Updater task enabled. Context=%1 +. Language=French Tâche de mise à jour activée. Contexte=%1 +. Language=German Update-Aufgabe aktiviert. Kontext=%1 . @@ -350,8 +410,10 @@ MessageId=6011 SymbolicName=UPDATER_ERROR Language=English Updater error. Context=%1 Step=%2 Error=%3 +. Language=French Erreur de mise à jour. Contexte=%1 Étape=%2 Erreur=%3 +. Language=German Update-Fehler. Kontext=%1 Schritt=%2 Fehler=%3 . @@ -360,8 +422,10 @@ MessageId=6020 SymbolicName=PEDM_ENABLED Language=English PEDM enabled. Context=%1 +. Language=French PEDM activé. Contexte=%1 +. Language=German PEDM aktiviert. Kontext=%1 . @@ -374,8 +438,10 @@ MessageId=7010 SymbolicName=RECORDING_STORAGE_LOW Language=English Recording storage low. Context=%1 RemainingBytes=%2 ThresholdBytes=%3 +. Language=French Espace d’enregistrement faible. Contexte=%1 OctetsRestants=%2 Seuil=%3 +. Language=German Aufnahmespeicher niedrig. Kontext=%1 VerbleibendeBytes=%2 Schwelle=%3 . @@ -388,8 +454,10 @@ MessageId=9001 SymbolicName=DEBUG_OPTIONS_ENABLED Language=English Debug options enabled. Context=%1 Options=%2 +. Language=French Options de débogage activées. Contexte=%1 Options=%2 +. Language=German Debug-Optionen aktiviert. Kontext=%1 Optionen=%2 . @@ -398,8 +466,10 @@ MessageId=9002 SymbolicName=XMF_NOT_FOUND Language=English XMF not found. Context=%1 Path=%2 Error=%3 +. Language=French XMF introuvable. Contexte=%1 Chemin=%2 Erreur=%3 +. Language=German XMF nicht gefunden. Kontext=%1 Pfad=%2 Fehler=%3 . diff --git a/package/AgentPolicyConsent.Tests/DevolutionsAgentPolicyConsent.Tests.csproj b/package/AgentPolicyConsent.Tests/DevolutionsAgentPolicyConsent.Tests.csproj new file mode 100644 index 000000000..24bdd8852 --- /dev/null +++ b/package/AgentPolicyConsent.Tests/DevolutionsAgentPolicyConsent.Tests.csproj @@ -0,0 +1,22 @@ + + + net10.0-windows + win-x64 + true + false + enable + enable + + + + + + all + + + + + + diff --git a/package/AgentPolicyConsent.Tests/ProtocolTests.cs b/package/AgentPolicyConsent.Tests/ProtocolTests.cs new file mode 100644 index 000000000..5ce35de96 --- /dev/null +++ b/package/AgentPolicyConsent.Tests/ProtocolTests.cs @@ -0,0 +1,594 @@ +using System.Buffers.Binary; +using System.Security.AccessControl; +using System.Text.Json; +using DevolutionsAgentPolicyConsent; +using Microsoft.Win32.SafeHandles; +using Xunit; + +namespace DevolutionsAgentPolicyConsent.Tests; + +public sealed class ProtocolTests +{ + private const string RequestId = "0123456789abcdef0123456789abcdef"; + + [Fact] + public void ArgumentsRequireExactBoundIdentity() + { + Arguments parsed = Protocol.ParseArguments( + [ + "--protocol", "2.0", + "--pipe", $"UniGetUI.PolicyElevation.{RequestId}", + "--parent-pid", "42", + "--parent-created", "638900000000000000", + "--session", "1", + ]); + + Assert.Equal(42, parsed.ParentProcessId); + Assert.Equal((uint)1, parsed.SessionId); + } + + public sealed class TrustPolicyTests + { + [Fact] + public void OnlyTheCurrentSignerIsAccepted() + { + Assert.True(PeerLease.IsAllowedSigner(PeerLease.CurrentUiSignerSpkiSha256)); + Assert.False(PeerLease.IsAllowedSigner( + "99e7adb5894e242d87d32b8ad6cb5a1e0d2dd791a447bd7192c30189ef083fab")); + } + + [Fact] + public void LookalikeSignerIsRejected() + { + Assert.False(PeerLease.IsAllowedSigner(new string('0', 64))); + } + + [Fact] + public void AgentSignerRequiresKnownDevolutionsCertificate() + { + Assert.All( + PolicyConsentContract.DevolutionsSignerSha1Thumbprints, + thumbprint => Assert.True(PeerLease.IsAllowedDevolutionsSigner(thumbprint))); + Assert.False(PeerLease.IsAllowedDevolutionsSigner(new string('0', 40))); + } + + [Theory] + [InlineData("O:SYG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;0x1200A9;;;BU)", PeerLease.FileTamperRights, true)] + [InlineData("O:BUG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)", PeerLease.FileTamperRights, false)] + [InlineData("O:SYG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;GW;;;BU)", PeerLease.FileTamperRights, false)] + [InlineData("O:SYG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;0x6;;;AU)", PeerLease.ParentDirectoryTamperRights, false)] + [InlineData("O:SYG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;0x6;;;AU)", PeerLease.AncestorDirectoryTamperRights, true)] + [InlineData("O:SYG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;0x40;;;BU)", PeerLease.AncestorDirectoryTamperRights, false)] + public void ProtectedAgentPathRequiresTrustedOwnerAndWriters( + string sddl, + int tamperRights, + bool accepted) + { + RawSecurityDescriptor descriptor = new(sddl); + if (accepted) + { + PeerLease.VerifyTrustedSecurityDescriptor(descriptor, "test path", tamperRights); + } + else + { + Assert.Throws( + () => PeerLease.VerifyTrustedSecurityDescriptor(descriptor, "test path", tamperRights)); + } + } + + [Fact] + public void ProtectedAgentPathRejectsReparsePoints() + { + Assert.True(PeerLease.IsReparsePoint(PeerLease.FileAttributeReparsePoint)); + Assert.False(PeerLease.IsReparsePoint(0)); + Assert.True(PeerLease.IsDirectory(PeerLease.FileAttributeDirectory)); + Assert.False(PeerLease.IsDirectory(0)); + } + + [Fact] + public void SignerMatchingIsCaseSensitive() + { + Assert.False(PeerLease.IsAllowedSigner(PeerLease.CurrentUiSignerSpkiSha256.ToUpperInvariant())); + } + + [Theory] + [InlineData("2026.2.7")] + [InlineData("2026.2.7-preview")] + public void ProductBindingSupportsCurrentSignedHosts(string version) + { + Assert.True(PeerLease.IsSupportedUiIdentity("UniGetUI", "UniGetUI.dll", version)); + } + + [Theory] + [InlineData("Lookalike", "UniGetUI.dll", "2026.2.7")] + [InlineData("UniGetUI", "malware.exe", "2026.2.7")] + [InlineData("UniGetUI", "UniGetUI.dll", "3.3.6")] + [InlineData("UniGetUI", "UniGetUI.dll", "2026.2.6")] + public void ProductBindingRejectsLookalikes(string product, string originalFilename, string version) + { + Assert.False(PeerLease.IsSupportedUiIdentity(product, originalFilename, version)); + } + + [Theory] + [InlineData(41, 638900000000000000, 1)] + [InlineData(42, 638900000000000001, 1)] + [InlineData(42, 638900000000000000, 2)] + public void ProcessIdentityRejectsPidReuseAndSessionMismatch(int pid, long created, uint session) + { + Arguments expected = new("pipe", 42, 638900000000000000, 1); + Assert.False(PeerLease.MatchesProcessIdentity(expected, pid, created, session)); + } + + [Fact] + public void ProcessIdentityAcceptsExactRetainedInstance() + { + Arguments expected = new("pipe", 42, 638900000000000000, 1); + Assert.True(PeerLease.MatchesProcessIdentity(expected, 42, 638900000000000000, 1)); + } + + [Theory] + [InlineData(@"C:\Program Files\UniGetUI\UniGetUI.exe", true)] + [InlineData(@"\\server\share\UniGetUI.exe", false)] + [InlineData(@"\Device\Mup\server\share\UniGetUI.exe", false)] + [InlineData(@"\Device\WebDavRedirector\server\share\UniGetUI.exe", false)] + [InlineData(@"relative\UniGetUI.exe", false)] + public void ParentImageMustUseAFixedLocalVolume(string path, bool expected) + { + Assert.Equal(expected, PeerLease.IsSupportedLocalImagePath(path)); + } + + [Theory] + [InlineData(@"\\?\C:\Program Files\UniGetUI\UniGetUI.exe", @"C:\Program Files\UniGetUI\UniGetUI.exe")] + [InlineData(@"\\?\UNC\server\share\UniGetUI.exe", @"\\server\share\UniGetUI.exe")] + public void RetainedImagePathRemovesOnlyExtendedPrefix(string input, string expected) + { + Assert.Equal(expected, PeerLease.NormalizeFinalPath(input)); + } + + [Fact] + public void BrokerServerRequiresExactAgentSiblingPath() + { + Assert.True(BrokerServerLease.IsExpectedPath( + @"C:\Program Files\Devolutions\Agent\DevolutionsAgent.exe", + @"c:\PROGRAM FILES\Devolutions\Agent\DevolutionsAgent.exe")); + Assert.False(BrokerServerLease.IsExpectedPath( + @"C:\Users\Alice\DevolutionsAgent.exe", + @"C:\Program Files\Devolutions\Agent\DevolutionsAgent.exe")); + Assert.True(BrokerServerLease.IsExpectedPath( + @"D:\Managed Apps\Agent\DevolutionsAgent.exe", + @"d:\managed apps\Agent\DevolutionsAgent.exe")); + } + + [Fact] + public void AuthenticodeSignerComesFromRetainedImageHandle() + { + string path = Path.Combine(AppContext.BaseDirectory, "testhost.exe"); + using SafeFileHandle image = OpenImage(path); + using var certificate = PeerLease.VerifyAuthenticodeSigner(path, image, "test host"); + Assert.NotEmpty(certificate.RawData); + } + + [Fact] + public void AuthenticodeRequiresFreshWholeChainRevocation() + { + Native.WinTrustData data = new(IntPtr.Zero); + + Assert.Equal(Native.WtdRevokeWholeChain, data.RevocationChecks); + Assert.Equal( + Native.WtdRevocationCheckChain | Native.WtdDisableMd2Md4, + data.ProviderFlags); + Assert.Equal(0u, data.ProviderFlags & Native.WtdCacheOnlyUrlRetrieval); + } + + [Theory] + [InlineData(0, true)] + [InlineData(unchecked((int)0x800B010C), false)] + [InlineData(unchecked((int)0x80092012), false)] + [InlineData(unchecked((int)0x80092013), false)] + [InlineData(unchecked((int)0x800B010E), false)] + public void AuthenticodeFailsClosedForIndeterminateStatus(int status, bool accepted) + { + Assert.Equal(accepted, PeerLease.IsAuthenticodeStatusAccepted(status)); + } + + [Fact] + public void ProcessImageMappingAcceptsOnlyCurrentMappedImage() + { + using SafeProcessHandle process = Native.OpenProcess( + PeerLease.ProcessQueryInformation | + PeerLease.ProcessQueryLimitedInformation | + PeerLease.Synchronize, + false, + Environment.ProcessId); + Assert.False(process.IsInvalid); + + string processPath = PeerLease.ImagePath(process); + using SafeFileHandle processImage = OpenImage(processPath); + PeerLease.VerifyImageMapping(process, processImage); + + using SafeFileHandle differentImage = + OpenImage(Path.Combine(AppContext.BaseDirectory, "DevolutionsAgentPolicyConsent.exe")); + Assert.Throws(() => PeerLease.VerifyImageMapping(process, differentImage)); + } + + [Fact] + public void BrokerServerRejectsNonSystemProcessToken() + { + using SafeProcessHandle process = Native.OpenProcess( + PeerLease.ProcessQueryLimitedInformation, + false, + Environment.ProcessId); + Assert.False(process.IsInvalid); + Assert.False(PeerLease.IsLocalSystemProcess(process)); + } + + [Fact] + public void FileIdentityRejectsDifferentImage() + { + using SafeFileHandle first = OpenImage(Path.Combine(AppContext.BaseDirectory, "testhost.exe")); + using SafeFileHandle same = OpenImage(Path.Combine(AppContext.BaseDirectory, "testhost.exe")); + using SafeFileHandle different = + OpenImage(Path.Combine(AppContext.BaseDirectory, "DevolutionsAgentPolicyConsent.exe")); + + Assert.True(PeerLease.SameFile(first, same)); + Assert.False(PeerLease.SameFile(first, different)); + } + + private static SafeFileHandle OpenImage(string path) + { + SafeFileHandle image = Native.CreateFile( + path, + PeerLease.GenericRead | PeerLease.FileExecute | PeerLease.Synchronize, + PeerLease.FileShareRead, + IntPtr.Zero, + PeerLease.OpenExisting, + 0, + IntPtr.Zero); + Assert.False(image.IsInvalid); + return image; + } + } + + [Theory] + [InlineData("--extra")] + [InlineData("--pipe")] + public void ArgumentsRejectUnknownOrDuplicateNames(string name) + { + string[] args = + [ + "--protocol", "2.0", + "--pipe", $"UniGetUI.PolicyElevation.{RequestId}", + "--parent-pid", "42", + "--parent-created", "638900000000000000", + name, "1", + ]; + + Assert.Throws(() => Protocol.ParseArguments(args)); + } + + [Fact] + public async Task FrameUsesBigEndianLengthAndRoundTrips() + { + byte[] body = [1, 2, 3, 4]; + using MemoryStream stream = new(); + await Protocol.WriteFrameAsync(stream, body, body.Length, CancellationToken.None); + Assert.Equal((uint)body.Length, BinaryPrimitives.ReadUInt32BigEndian(stream.GetBuffer())); + stream.Position = 0; + Assert.Equal(body, await Protocol.ReadFrameAsync(stream, body.Length, CancellationToken.None)); + } + + [Fact] + public async Task OversizedFrameIsRejectedBeforeBodyRead() + { + byte[] header = new byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(header, 128); + using MemoryStream stream = new(header); + await Assert.ThrowsAsync( + () => Protocol.ReadFrameAsync(stream, 127, CancellationToken.None)); + } + + [Fact] + public async Task ZeroLengthAndTruncatedFramesAreRejected() + { + using MemoryStream empty = new(new byte[4]); + await Assert.ThrowsAsync( + () => Protocol.ReadFrameAsync(empty, 128, CancellationToken.None)); + + using MemoryStream truncated = new([0, 0, 0, 2, 1]); + await Assert.ThrowsAsync( + () => Protocol.ReadFrameAsync(truncated, 128, CancellationToken.None)); + } + + [Fact] + public async Task FrameReadHonorsCancellation() + { + using CancellationTokenSource cancellation = new(TimeSpan.FromMilliseconds(25)); + await Assert.ThrowsAnyAsync( + () => Protocol.ReadFrameAsync(new BlockingStream(), 128, cancellation.Token)); + } + + [Fact] + public void RequestRejectsUnknownJsonMembers() + { + string json = + $$"""{"protocolVersion":"2.0","requestId":"{{RequestId}}","operation":"Update","conflictHandling":"Reject","expectedStoreToken":"a","validationReceipt":"b","draft":{},"command":"cmd.exe"}"""; + + Assert.Throws( + () => JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.ElevationRequest)); + } + + [Fact] + public void OfficialRequestContainsOnlyPolicyReplacementFields() + { + using JsonDocument draft = JsonDocument.Parse("""{"Metadata":{"Id":"tests.policy"}}"""); + ElevationRequest request = new( + "2.0", + RequestId, + "Update", + "ConfirmOverwrite", + "token", + "receipt", + draft.RootElement.Clone()); + + using JsonDocument official = JsonDocument.Parse(BrokerClient.CreateOfficialRequest(request)); + string[] names = official.RootElement.EnumerateObject().Select(property => property.Name).ToArray(); + Assert.Equal( + [ + "RequestKind", + "RequestVersion", + "ExpectedStoreToken", + "Operation", + "ConflictHandling", + "Draft", + "ValidationReceipt", + ], names); + } + + [Fact] + public void RequestRejectsLegacyAcknowledgementMember() + { + string legacyAcknowledgement = string.Concat("Warnings", "Acknowledged"); + string requestJson = + $$"""{"protocolVersion":"2.0","requestId":"{{RequestId}}","operation":"Update","conflictHandling":"Reject","expectedStoreToken":"a","validationReceipt":"b","draft":{},"{{legacyAcknowledgement}}":false}"""; + Assert.Throws( + () => JsonSerializer.Deserialize(requestJson, ProtocolJsonContext.Default.ElevationRequest)); + + using JsonDocument draft = JsonDocument.Parse("[]"); + ElevationRequest request = new("2.0", RequestId, "Update", "Reject", "a", "b", draft.RootElement); + Assert.Throws(() => Protocol.ValidateRequest(request)); + } + + [Fact] + public void RequestRejectsExplicitNullRequiredMembers() + { + string nullRequestId = + """{"protocolVersion":"2.0","requestId":null,"operation":"Update","conflictHandling":"Reject","expectedStoreToken":"a","validationReceipt":"b","draft":{}}"""; + + ElevationRequest request = JsonSerializer.Deserialize( + nullRequestId, + ProtocolJsonContext.Default.ElevationRequest) + ?? throw new InvalidOperationException("request should deserialize"); + Assert.Throws(() => Protocol.ValidateRequest(request)); + } + + [Theory] + [InlineData(@"\\.\pipe\Devolutions.Now.PackageBroker.v1", "Devolutions.Now.PackageBroker.v1")] + [InlineData("custom-broker", "custom-broker")] + public void BrokerPipeDiscoveryNormalizesLocalPipeNames(string configured, string expected) + { + Assert.Equal(expected, BrokerClient.NormalizeBrokerPipeName(configured)); + } + + [Theory] + [InlineData("")] + [InlineData(@"\\server\pipe\broker")] + [InlineData(@"\\.\pipe\")] + public void BrokerPipeDiscoveryRejectsNonlocalOrEmptyNames(string configured) + { + Assert.ThrowsAny(() => BrokerClient.NormalizeBrokerPipeName(configured)); + } + + [Fact] + public async Task OversizedOfficialRequestIsRejectedBeforeBrokerConnection() + { + using JsonDocument draft = JsonDocument.Parse($$"""{"padding":"{{new string('x', Protocol.MaxRequestBodyBytes)}}" }"""); + ElevationRequest request = new( + "2.0", + RequestId, + "Update", + "Reject", + "token", + "receipt", + draft.RootElement.Clone()); + + ElevationResponse response = await BrokerClient.ReplaceAsync(request, CancellationToken.None); + + Assert.Equal("Rejected", response.Disposition); + Assert.Equal("InvalidRequest", response.BrokerErrorCode); + } + + [Theory] + [InlineData("Delete", "Reject")] + [InlineData("Update", "Overwrite")] + public void RequestRejectsUnknownOperations(string operation, string conflictHandling) + { + using JsonDocument draft = JsonDocument.Parse("{}"); + ElevationRequest request = new( + "2.0", + RequestId, + operation, + conflictHandling, + "a", + "b", + draft.RootElement); + + Assert.Throws(() => Protocol.ValidateRequest(request)); + } + + [Theory] + [InlineData("a", true)] + [InlineData("A0._~:-", true)] + [InlineData("a/b", false)] + [InlineData("a b", false)] + [InlineData("a\"b", false)] + [InlineData("-token", false)] + public void CredentialsMatchOfficialPolicyApiCharacterRules(string value, bool accepted) + { + Assert.Equal(accepted, Protocol.IsCredential(value, 512)); + } + + [Fact] + public void CommittedResponseContainsOnlyStoreToken() + { + ElevationResponse response = new( + "2.0", + RequestId, + "Committed", + null, + null, + "new-token", + null, + null, + null); + + Protocol.ValidateResponse(response); + string json = JsonSerializer.Serialize(response, ProtocolJsonContext.Default.ElevationResponse); + Assert.DoesNotContain("payload", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("message", json, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("Active", "policy.id")] + [InlineData("Missing", null)] + [InlineData("Invalid", null)] + public void StaleRejectionCarriesBoundedConflictContext(string state, string? policyId) + { + ElevationResponse response = new( + "2.0", + RequestId, + "Rejected", + 409, + "StalePolicyStoreToken", + null, + "current-token", + state, + policyId); + + Protocol.ValidateResponse(response); + } + + [Fact] + public void UnknownResponseCannotClaimCommitOrConflict() + { + ElevationResponse response = new( + "2.0", + RequestId, + "Unknown", + null, + "Timeout", + "claimed-token", + null, + null, + null); + + Assert.Throws(() => Protocol.ValidateResponse(response)); + } + + [Fact] + public void ResponseRequiresExplicitNullableMembers() + { + string missingConflictFields = + $$$"""{"protocolVersion":"2.0","requestId":"{{{RequestId}}}","disposition":"Unknown","brokerStatusCode":null,"brokerErrorCode":"Timeout","committedStoreToken":null}"""; + + Assert.Throws( + () => JsonSerializer.Deserialize(missingConflictFields, ProtocolJsonContext.Default.ElevationResponse)); + } + + [Fact] + public void MaximumValidStaleResponseFitsWireBudget() + { + ElevationResponse response = new( + "2.0", + RequestId, + "Rejected", + 409, + "StalePolicyStoreToken", + null, + "T" + new string('~', 511), + "Active", + "P" + new string('~', 2047)); + + Protocol.ValidateResponse(response); + byte[] body = JsonSerializer.SerializeToUtf8Bytes( + response, + ProtocolJsonContext.Default.ElevationResponse); + Assert.InRange(body.Length, 1, Protocol.MaxResponseBodyBytes); + } + + [Fact] + public void BrokerSuccessMapsToCompactCommittedAcknowledgement() + { + ElevationResponse response = BrokerClient.ParseResponse( + RequestId, + HttpResponse( + 200, + """{"ResponseKind":"PolicyReplacementResponse","ResponseVersion":"1.0","Management":{"StoreToken":"new-token"}}""")); + + Assert.Equal("Committed", response.Disposition); + Assert.Equal("new-token", response.CommittedStoreToken); + Assert.Null(response.BrokerStatusCode); + } + + [Fact] + public void BrokerStaleErrorMapsExactConflictContext() + { + ElevationResponse response = BrokerClient.ParseResponse( + RequestId, + HttpResponse( + 409, + """{"ResponseKind":"ErrorResponse","ResponseVersion":"1.0","Code":"StalePolicyStoreToken","Management":{"StoreToken":"current-token","State":"Active","Policy":{"Metadata":{"Id":"policy.id"}}}}""")); + + Assert.Equal("Rejected", response.Disposition); + Assert.Equal(409, response.BrokerStatusCode); + Assert.Equal("current-token", response.ConflictStoreToken); + Assert.Equal("Active", response.ConflictState); + Assert.Equal("policy.id", response.ConflictPolicyId); + } + + [Fact] + public void EmptyBrokerResponseMapsToUnknown() + { + ElevationResponse response = BrokerClient.ParseResponse( + RequestId, + HttpResponse(503, string.Empty)); + + Assert.Equal("Unknown", response.Disposition); + Assert.Equal(503, response.BrokerStatusCode); + Assert.Equal("EmptyResponse", response.BrokerErrorCode); + } + + private static byte[] HttpResponse(int status, string body) => + System.Text.Encoding.UTF8.GetBytes( + $"HTTP/1.1 {status} Test\r\nContent-Length: {System.Text.Encoding.UTF8.GetByteCount(body)}\r\n\r\n{body}"); + + private sealed class BlockingStream : Stream + { + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() => throw new NotSupportedException(); + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return 0; + } + } +} diff --git a/package/AgentPolicyConsent/BrokerClient.cs b/package/AgentPolicyConsent/BrokerClient.cs new file mode 100644 index 000000000..ac6c0773f --- /dev/null +++ b/package/AgentPolicyConsent/BrokerClient.cs @@ -0,0 +1,337 @@ +using System.Buffers; +using System.IO.Pipes; +using Microsoft.Win32; +using System.Text; +using System.Text.Json; + +namespace DevolutionsAgentPolicyConsent; + +internal static class BrokerClient +{ + private const string BrokerPipeNameValue = "BrokerPipeName"; + private const string DiscoveryKey = @"SOFTWARE\Devolutions\Agent\PolicyConsentHelper"; + private const int MaximumHeaderBytes = 64 * 1024; + private const int MaximumBrokerResponseBytes = 50_606_928; + private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5); + + internal static async Task ReplaceAsync( + ElevationRequest request, + CancellationToken cancellationToken) + { + try + { + byte[] body = CreateOfficialRequest(request); + using NamedPipeClientStream pipe = new( + ".", + ReadBrokerPipeName(), + PipeDirection.InOut, + PipeOptions.Asynchronous | PipeOptions.WriteThrough, + System.Security.Principal.TokenImpersonationLevel.Anonymous); + + using CancellationTokenSource connectTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + connectTimeout.CancelAfter(ConnectTimeout); + await pipe.ConnectAsync(connectTimeout.Token); + using BrokerServerLease broker = await OpenBrokerServerAsync(pipe, cancellationToken); + + byte[] headers = Encoding.ASCII.GetBytes( + $"PUT /v1/policy HTTP/1.1\r\nHost: now-package-broker\r\nConnection: close\r\n" + + $"Content-Type: application/json\r\nAccept: application/json\r\nContent-Length: {body.Length}\r\n\r\n"); + await pipe.WriteAsync(headers, cancellationToken); + await pipe.WriteAsync(body, cancellationToken); + await pipe.FlushAsync(cancellationToken); + + byte[] response = await ReadBoundedAsync( + pipe, + MaximumBrokerResponseBytes + MaximumHeaderBytes, + cancellationToken); + return ParseResponse(request.RequestId, response); + } + catch (OperationCanceledException) + { + return Unknown(request.RequestId, null, "Timeout"); + } + catch (IOException) + { + return Unknown(request.RequestId, null, "BrokerUnavailable"); + } + catch (JsonException) + { + return Unknown(request.RequestId, null, "InvalidResponse"); + } + catch (BrokerResponseException error) + { + return Unknown(request.RequestId, error.StatusCode, "InvalidResponse"); + } + catch (BrokerAuthenticationException) + { + return Rejected(request.RequestId, "Unauthorized"); + } + catch (BrokerDiscoveryException) + { + return Rejected(request.RequestId, "BrokerUnavailable"); + } + catch (InvalidOperationException) + { + return Unknown(request.RequestId, null, "InvalidResponse"); + } + catch (ProtocolException) + { + return Rejected(request.RequestId, "InvalidRequest"); + } + } + + internal static string NormalizeBrokerPipeName(string pipeName) + { + const string LocalPipePrefix = @"\\.\pipe\"; + if (pipeName.StartsWith(LocalPipePrefix, StringComparison.OrdinalIgnoreCase)) + { + pipeName = pipeName[LocalPipePrefix.Length..]; + } + if (pipeName.Length is 0 or > 256 || + pipeName.IndexOf('\0') >= 0 || + pipeName.Contains('\\')) + { + throw new BrokerDiscoveryException(); + } + return pipeName; + } + + internal static byte[] CreateOfficialRequest(ElevationRequest request) + { + ArrayBufferWriter buffer = new(); + using Utf8JsonWriter writer = new(buffer); + writer.WriteStartObject(); + writer.WriteString("RequestKind", "PolicyReplacementRequest"); + writer.WriteString("RequestVersion", "1.0"); + writer.WriteString("ExpectedStoreToken", request.ExpectedStoreToken); + writer.WriteString("Operation", request.Operation); + writer.WriteString("ConflictHandling", request.ConflictHandling); + writer.WritePropertyName("Draft"); + request.Draft.WriteTo(writer); + writer.WriteString("ValidationReceipt", request.ValidationReceipt); + writer.WriteEndObject(); + writer.Flush(); + if (buffer.WrittenCount > 16_777_216) + { + throw new ProtocolException("official policy request exceeds broker limit"); + } + return buffer.WrittenSpan.ToArray(); + } + + internal static ElevationResponse ParseResponse(string requestId, byte[] response) + { + ReadOnlySpan delimiter = "\r\n\r\n"u8; + int headerEnd = response.AsSpan().IndexOf(delimiter); + if (headerEnd < 0 || headerEnd > MaximumHeaderBytes) + { + throw new BrokerResponseException(null); + } + + string statusLine = Encoding.ASCII.GetString(response.AsSpan(0, headerEnd)).Split("\r\n", 2)[0]; + string[] statusParts = statusLine.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries); + if (statusParts.Length < 2 || !int.TryParse(statusParts[1], out int status)) + { + throw new BrokerResponseException(null); + } + + ReadOnlyMemory body = response.AsMemory(headerEnd + delimiter.Length); + if (body.IsEmpty) + { + return Unknown(requestId, status, "EmptyResponse"); + } + + try + { + return ParseResponseBody(requestId, status, body); + } + catch (Exception error) when (error is JsonException or InvalidOperationException or ProtocolException) + { + throw new BrokerResponseException(status, error); + } + } + + private static ElevationResponse ParseResponseBody( + string requestId, + int status, + ReadOnlyMemory body) + { + using JsonDocument document = JsonDocument.Parse(body); + JsonElement payload = document.RootElement; + if (status is >= 200 and <= 299) + { + RequireString(payload, "ResponseKind", "PolicyReplacementResponse"); + RequireString(payload, "ResponseVersion", "1.0"); + string token = RequireNestedString(payload, "Management", "StoreToken"); + ElevationResponse committed = new( + Protocol.Version, + requestId, + "Committed", + null, + null, + token, + null, + null, + null); + Protocol.ValidateResponse(committed); + return committed; + } + + RequireString(payload, "ResponseKind", "ErrorResponse"); + RequireString(payload, "ResponseVersion", "1.0"); + string code = RequireString(payload, "Code"); + string? conflictToken = null; + string? conflictState = null; + string? conflictPolicyId = null; + if (code == "StalePolicyStoreToken") + { + JsonElement management = RequireObject(payload, "Management"); + conflictToken = RequireString(management, "StoreToken"); + conflictState = RequireString(management, "State"); + if (conflictState == "Active") + { + JsonElement policy = RequireObject(management, "Policy"); + JsonElement metadata = RequireObject(policy, "Metadata"); + conflictPolicyId = RequireString(metadata, "Id"); + } + } + ElevationResponse rejected = new( + Protocol.Version, + requestId, + "Rejected", + status, + Truncate(code, 64), + null, + conflictToken, + conflictState, + conflictPolicyId); + Protocol.ValidateResponse(rejected); + return rejected; + } + + private static void RequireString(JsonElement payload, string property, string expected) + { + if (!payload.TryGetProperty(property, out JsonElement value) || + value.ValueKind != JsonValueKind.String || + value.GetString() != expected) + { + throw new InvalidOperationException("broker response contract mismatch"); + } + } + + private static string RequireString(JsonElement payload, string property) + { + if (!payload.TryGetProperty(property, out JsonElement value) || + value.ValueKind != JsonValueKind.String || + value.GetString() is not { } result) + { + throw new InvalidOperationException("broker response contract mismatch"); + } + return result; + } + + private static string RequireNestedString(JsonElement payload, string parent, string property) => + RequireString(RequireObject(payload, parent), property); + + private static JsonElement RequireObject(JsonElement payload, string property) + { + if (!payload.TryGetProperty(property, out JsonElement value) || + value.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException("broker response contract mismatch"); + } + return value; + } + + private static async Task ReadBoundedAsync(Stream stream, int maximum, CancellationToken cancellationToken) + { + using MemoryStream response = new(); + byte[] buffer = new byte[16 * 1024]; + while (true) + { + int read = await stream.ReadAsync(buffer, cancellationToken); + if (read == 0) + { + return response.ToArray(); + } + if (response.Length + read > maximum) + { + throw new InvalidOperationException("broker response exceeds limit"); + } + response.Write(buffer, 0, read); + } + } + + private static ElevationResponse Unknown(string requestId, int? statusCode, string errorCode) => + new(Protocol.Version, requestId, "Unknown", statusCode, errorCode, null, null, null, null); + + private static ElevationResponse Rejected(string requestId, string errorCode) => + new(Protocol.Version, requestId, "Rejected", null, errorCode, null, null, null, null); + + private static string ReadBrokerPipeName() + { + using RegistryKey machine = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64); + using RegistryKey? discovery = machine.OpenSubKey(DiscoveryKey); + if (discovery?.GetValue(BrokerPipeNameValue) is not string pipeName) + { + throw new BrokerDiscoveryException(); + } + return NormalizeBrokerPipeName(pipeName); + } + + private static async Task OpenBrokerServerAsync( + NamedPipeClientStream pipe, + CancellationToken cancellationToken) + { + Task open = Task.Run(() => BrokerServerLease.Open(pipe.SafePipeHandle)); + try + { + return await open.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + DisposeLateResult(open); + throw; + } + catch (Exception error) + { + DisposeLateResult(open); + throw new BrokerAuthenticationException(error); + } + } + + private static void DisposeLateResult(Task open) + { + _ = open.ContinueWith( + static completed => + { + if (completed.Status == TaskStatus.RanToCompletion) + { + completed.Result.Dispose(); + } + _ = completed.Exception; + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private static string Truncate(string value, int maximum) => + value.Length <= maximum ? value : value[..maximum]; + + private sealed class BrokerResponseException(int? statusCode, Exception? innerException = null) + : Exception("broker response contract mismatch", innerException) + { + internal int? StatusCode { get; } = statusCode; + } + + private sealed class BrokerAuthenticationException(Exception innerException) + : Exception("broker server authentication failed", innerException); + + private sealed class BrokerDiscoveryException : InvalidOperationException + { + internal BrokerDiscoveryException() + : base("broker pipe discovery is unavailable") + { + } + } +} diff --git a/package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj b/package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj new file mode 100644 index 000000000..a8257d4dc --- /dev/null +++ b/package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj @@ -0,0 +1,23 @@ + + + WinExe + net10.0-windows + win-x64 + true + true + true + true + false + enable + enable + true + app.manifest + DevolutionsAgentPolicyConsent + DevolutionsAgentPolicyConsent + Devolutions Agent Policy Consent + Devolutions Inc. + + + + + diff --git a/package/AgentPolicyConsent/PeerTrust.cs b/package/AgentPolicyConsent/PeerTrust.cs new file mode 100644 index 000000000..82fefd51a --- /dev/null +++ b/package/AgentPolicyConsent/PeerTrust.cs @@ -0,0 +1,979 @@ +using Microsoft.Win32.SafeHandles; +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Security.Principal; +using System.Text; + +namespace DevolutionsAgentPolicyConsent; + +internal sealed class PeerLease : IDisposable +{ + internal const string CurrentUiSignerSpkiSha256 = + PolicyConsentContract.CurrentUiSignerSpkiSha256; + + internal const uint ProcessQueryLimitedInformation = 0x1000; + internal const uint ProcessQueryInformation = 0x0400; + internal const uint Synchronize = 0x0010_0000; + internal const uint GenericRead = 0x8000_0000; + internal const uint FileExecute = 0x20; + internal const uint FileReadAttributes = 0x80; + internal const uint ReadControl = 0x0002_0000; + internal const uint FileShareRead = 0x1; + internal const uint FileShareWrite = 0x2; + internal const uint OpenExisting = 3; + internal const uint FileAttributeDirectory = 0x10; + internal const uint FileAttributeReparsePoint = 0x400; + internal const uint FileFlagBackupSemantics = 0x0200_0000; + internal const uint FileFlagOpenReparsePoint = 0x0020_0000; + internal const uint DriveFixed = 3; + internal const int ProcessImageFileMapping = 44; + internal const uint StillActive = 259; + + private readonly SafeProcessHandle process; + private readonly SafeFileHandle image; + private readonly int processId; + private readonly long createdUtcTicks; + private readonly uint sessionId; + + private PeerLease( + SafeProcessHandle process, + SafeFileHandle image, + int processId, + long createdUtcTicks, + uint sessionId) + { + this.process = process; + this.image = image; + this.processId = processId; + this.createdUtcTicks = createdUtcTicks; + this.sessionId = sessionId; + } + + internal static PeerLease Open(Arguments arguments) + { + SafeProcessHandle process = Native.OpenProcess( + ProcessQueryInformation | ProcessQueryLimitedInformation | Synchronize, + false, + arguments.ParentProcessId); + if (process.IsInvalid) + { + throw new Win32Exception(); + } + + try + { + long created = CreationTime(process); + uint session = SessionId(arguments.ParentProcessId); + if (!MatchesProcessIdentity( + arguments, + arguments.ParentProcessId, + created, + session)) + { + throw new InvalidOperationException("parent process identity mismatch"); + } + + string path = ImagePath(process); + if (!IsSupportedLocalImagePath(path)) + { + throw new InvalidOperationException("parent image is not on a supported local volume"); + } + SafeFileHandle image = Native.CreateFile( + path, + GenericRead | FileExecute | Synchronize, + FileShareRead, + IntPtr.Zero, + OpenExisting, + 0, + IntPtr.Zero); + if (image.IsInvalid) + { + throw new Win32Exception(); + } + + try + { + VerifyImageMapping(process, image); + string retainedPath = FinalPath(image); + if (!IsSupportedLocalImagePath(retainedPath)) + { + throw new InvalidOperationException("parent image is not on a supported local volume"); + } + VerifyImageMetadata(retainedPath, image); + using X509Certificate2 signer = VerifyAuthenticodeSigner(retainedPath, image, "parent image"); + VerifySigner(signer); + VerifyImageMapping(process, image); + EnsureActive(process); + return new PeerLease(process, image, arguments.ParentProcessId, created, session); + } + catch + { + image.Dispose(); + throw; + } + } + catch + { + process.Dispose(); + throw; + } + } + + internal void VerifyConnectedServer(int serverProcessId) + { + Arguments expected = new(string.Empty, processId, createdUtcTicks, sessionId); + if (!MatchesProcessIdentity(expected, serverProcessId, CreationTime(process), SessionId(serverProcessId))) + { + throw new InvalidOperationException("connected server process identity mismatch"); + } + VerifyImageMapping(process, image); + EnsureActive(process); + } + + internal static bool IsAllowedSigner(string digest) => + FixedTimeEqualsHex(digest, CurrentUiSignerSpkiSha256); + + internal static bool IsAllowedDevolutionsSigner(string thumbprint) => + PolicyConsentContract.DevolutionsSignerSha1Thumbprints.Any( + expected => FixedTimeEqualsHex(thumbprint, expected, 40)); + + internal static bool IsSupportedUiIdentity(string? productName, string? originalFilename, string? productVersion) => + string.Equals(productName, "UniGetUI", StringComparison.Ordinal) && + string.Equals(originalFilename, "UniGetUI.dll", StringComparison.OrdinalIgnoreCase) && + productVersion is not null && + Version.TryParse(productVersion.Split(['+', '-'], StringSplitOptions.TrimEntries)[0], out Version? parsed) && + parsed >= new Version(2026, 2, 7); + + internal static bool MatchesProcessIdentity( + Arguments expected, + int processId, + long createdUtcTicks, + uint sessionId) => + processId == expected.ParentProcessId && + createdUtcTicks == expected.ParentCreatedUtcTicks && + sessionId == expected.SessionId; + + public void Dispose() + { + image.Dispose(); + process.Dispose(); + } + + private static void VerifyImageMetadata(string path, SafeFileHandle retainedImage) + { + VerifyPathIdentity(path, retainedImage); + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidOperationException("parent image is a reparse point"); + } + + FileVersionInfo version = FileVersionInfo.GetVersionInfo(path); + if (!IsSupportedUiIdentity(version.ProductName, version.OriginalFilename, version.ProductVersion)) + { + throw new InvalidOperationException("parent image product identity mismatch"); + } + VerifyPathIdentity(path, retainedImage); + } + + private static void VerifyPathIdentity(string path, SafeFileHandle retainedImage) + { + using SafeFileHandle reopened = Native.CreateFile( + path, + GenericRead | FileExecute | Synchronize, + FileShareRead, + IntPtr.Zero, + OpenExisting, + 0, + IntPtr.Zero); + if (reopened.IsInvalid) + { + throw new Win32Exception(); + } + if (!SameFile(retainedImage, reopened)) + { + throw new InvalidOperationException("parent image path no longer identifies the retained image"); + } + } + + internal static bool SameFile(SafeFileHandle left, SafeFileHandle right) + { + if (!Native.GetFileInformationByHandle(left, out Native.ByHandleFileInformation leftInfo) || + !Native.GetFileInformationByHandle(right, out Native.ByHandleFileInformation rightInfo)) + { + throw new Win32Exception(); + } + return leftInfo.VolumeSerialNumber == rightInfo.VolumeSerialNumber && + leftInfo.FileIndexHigh == rightInfo.FileIndexHigh && + leftInfo.FileIndexLow == rightInfo.FileIndexLow; + } + + internal static bool IsSupportedLocalImagePath(string path) + { + if (!Path.IsPathFullyQualified(path) || + path.StartsWith(@"\\", StringComparison.Ordinal) || + Path.GetPathRoot(path) is not { Length: 3 } root) + { + return false; + } + return Native.GetDriveType(root) == DriveFixed; + } + + internal static string FinalPath(SafeFileHandle image) + { + uint capacity = 260; + while (capacity <= 32_768) + { + StringBuilder path = new(checked((int)capacity)); + uint length = Native.GetFinalPathNameByHandle(image, path, capacity, 0); + if (length == 0) + { + throw new Win32Exception(); + } + if (length < capacity) + { + return NormalizeFinalPath(path.ToString()); + } + capacity = length + 1; + } + throw new InvalidOperationException("parent image final path is too long"); + } + + internal static string NormalizeFinalPath(string path) + { + const string ExtendedPrefix = @"\\?\"; + const string ExtendedUncPrefix = @"\\?\UNC\"; + if (path.StartsWith(ExtendedUncPrefix, StringComparison.OrdinalIgnoreCase)) + { + return @"\\" + path[ExtendedUncPrefix.Length..]; + } + return path.StartsWith(ExtendedPrefix, StringComparison.Ordinal) + ? path[ExtendedPrefix.Length..] + : path; + } + + internal static bool IsLocalSystemProcess(SafeProcessHandle process) + { + if (!Native.OpenProcessToken(process, 0x0008, out SafeAccessTokenHandle token)) + { + throw new Win32Exception(); + } + using (token) + { + _ = Native.GetTokenInformation(token, 1, IntPtr.Zero, 0, out uint length); + if (length == 0) + { + throw new Win32Exception(); + } + + IntPtr information = Marshal.AllocHGlobal(checked((int)length)); + try + { + if (!Native.GetTokenInformation(token, 1, information, length, out _)) + { + throw new Win32Exception(); + } + IntPtr sid = Marshal.ReadIntPtr(information); + return Native.IsWellKnownSid(sid, 22); + } + finally + { + Marshal.FreeHGlobal(information); + } + } + } + + internal static X509Certificate2 VerifyAuthenticodeSigner( + string path, + SafeFileHandle image, + string subject) + { + Guid action = new("00AAC56B-CD44-11d0-8CC2-00C04FC295EE"); + Native.WinTrustFileInfo file = new(path, image.DangerousGetHandle()); + IntPtr filePointer = Marshal.AllocHGlobal(Marshal.SizeOf()); + IntPtr dataPointer = Marshal.AllocHGlobal(Marshal.SizeOf()); + bool fileInitialized = false; + bool dataInitialized = false; + try + { + Marshal.StructureToPtr(file, filePointer, false); + fileInitialized = true; + Native.WinTrustData data = new(filePointer); + Marshal.StructureToPtr(data, dataPointer, false); + dataInitialized = true; + int status = Native.WinVerifyTrust(new IntPtr(-1), ref action, dataPointer); + if (!IsAuthenticodeStatusAccepted(status)) + { + throw new InvalidOperationException($"{subject} Authenticode validation failed (0x{status:X8})"); + } + data = Marshal.PtrToStructure(dataPointer); + IntPtr providerData = Native.WTHelperProvDataFromStateData(data.StateData); + IntPtr providerSigner = providerData == IntPtr.Zero + ? IntPtr.Zero + : Native.WTHelperGetProvSignerFromChain(providerData, 0, false, 0); + if (providerSigner == IntPtr.Zero) + { + throw new InvalidOperationException($"{subject} Authenticode signer is unavailable"); + } + + Native.CryptProviderSigner signer = Marshal.PtrToStructure(providerSigner); + if (signer.CertificateChainCount == 0 || signer.CertificateChain == IntPtr.Zero) + { + throw new InvalidOperationException($"{subject} Authenticode certificate chain is empty"); + } + Native.CryptProviderCertificate certificate = + Marshal.PtrToStructure(signer.CertificateChain); +#pragma warning disable SYSLIB0057 // WinVerifyTrust returns the certificate context for the exact retained image. + return new X509Certificate2(certificate.CertificateContext); +#pragma warning restore SYSLIB0057 + } + finally + { + if (dataInitialized) + { + Native.WinTrustData data = Marshal.PtrToStructure(dataPointer); + if (data.StateData != IntPtr.Zero) + { + data.StateAction = 2; + Marshal.StructureToPtr(data, dataPointer, true); + _ = Native.WinVerifyTrust(new IntPtr(-1), ref action, dataPointer); + } + Marshal.DestroyStructure(dataPointer); + } + if (fileInitialized) + { + Marshal.DestroyStructure(filePointer); + } + Marshal.FreeHGlobal(dataPointer); + Marshal.FreeHGlobal(filePointer); + } + } + + internal static bool IsAuthenticodeStatusAccepted(int status) => status == 0; + + private static void VerifySigner(X509Certificate2 certificate) + { + byte[] subjectPublicKeyInfo; + using (RSA? rsa = certificate.GetRSAPublicKey()) + { + if (rsa is not null) + { + subjectPublicKeyInfo = rsa.ExportSubjectPublicKeyInfo(); + } + else + { + using ECDsa? ecdsa = certificate.GetECDsaPublicKey(); + subjectPublicKeyInfo = ecdsa?.ExportSubjectPublicKeyInfo() + ?? throw new InvalidOperationException("unsupported parent signer key"); + } + } + + string digest = Convert.ToHexString(SHA256.HashData(subjectPublicKeyInfo)).ToLowerInvariant(); + if (!IsAllowedSigner(digest)) + { + throw new InvalidOperationException("parent image signer is not authorized"); + } + } + + private static bool FixedTimeEqualsHex(string candidate, string expected, int length = 64) + { + if (candidate.Length != length || + expected.Length != length || + candidate.AsSpan().IndexOfAnyExcept("0123456789abcdef") >= 0) + { + return false; + } + try + { + return CryptographicOperations.FixedTimeEquals( + Convert.FromHexString(candidate), + Convert.FromHexString(expected)); + } + catch (FormatException) + { + return false; + } + } + + internal static void VerifyImageMapping(SafeProcessHandle process, SafeFileHandle image) + { + IntPtr fileHandle = image.DangerousGetHandle(); + int status = Native.NtQueryInformationProcess( + process.DangerousGetHandle(), + ProcessImageFileMapping, + ref fileHandle, + IntPtr.Size, + out _); + if (status != 0) + { + throw new InvalidOperationException($"parent image mapping mismatch (0x{status:X8})"); + } + } + + internal static void VerifyProtectedPath(SafeFileHandle handle, string subject, int tamperRights) + { + if (!Native.GetFileInformationByHandle(handle, out Native.ByHandleFileInformation information)) + { + throw new Win32Exception(); + } + if (IsReparsePoint(information.FileAttributes)) + { + throw new InvalidOperationException($"{subject} is a reparse point"); + } + + uint error = Native.GetSecurityInfo( + handle, + 1, + 0x1 | 0x4, + out _, + out _, + out _, + out _, + out IntPtr securityDescriptor); + if (error != 0) + { + throw new Win32Exception(checked((int)error)); + } + + try + { + int length = checked((int)Native.GetSecurityDescriptorLength(securityDescriptor)); + byte[] bytes = new byte[length]; + Marshal.Copy(securityDescriptor, bytes, 0, length); + VerifyTrustedSecurityDescriptor(new RawSecurityDescriptor(bytes, 0), subject, tamperRights); + } + finally + { + _ = Native.LocalFree(securityDescriptor); + } + } + + internal static bool IsReparsePoint(uint attributes) => + (attributes & FileAttributeReparsePoint) != 0; + + internal static bool IsDirectory(uint attributes) => + (attributes & FileAttributeDirectory) != 0; + + internal const int FileTamperRights = + 0x0000_0002 | 0x0000_0004 | 0x0000_0010 | 0x0000_0100 | + 0x0001_0000 | 0x0004_0000 | 0x0008_0000 | 0x1000_0000 | 0x4000_0000; + internal const int ParentDirectoryTamperRights = + 0x0000_0002 | 0x0000_0004 | 0x0000_0040 | + 0x0001_0000 | 0x0004_0000 | 0x0008_0000 | 0x1000_0000 | 0x4000_0000; + internal const int AncestorDirectoryTamperRights = + 0x0000_0040 | 0x0001_0000 | 0x0004_0000 | 0x0008_0000 | 0x1000_0000; + + internal static void VerifyTrustedSecurityDescriptor( + RawSecurityDescriptor descriptor, + string subject, + int tamperRights) + { + if (descriptor.Owner is not SecurityIdentifier owner || !IsTrustedWriter(owner)) + { + throw new InvalidOperationException($"{subject} has an untrusted owner"); + } + if (!descriptor.ControlFlags.HasFlag(ControlFlags.DiscretionaryAclPresent) || + descriptor.DiscretionaryAcl is not { Count: > 0 } dacl) + { + throw new InvalidOperationException($"{subject} has no protective DACL"); + } + + foreach (GenericAce generic in dacl) + { + if (generic.AceFlags.HasFlag(AceFlags.InheritOnly) || !IsAccessAllowedAce(generic.AceType)) + { + continue; + } + if (generic is not QualifiedAce ace || ace is not KnownAce known) + { + throw new InvalidOperationException($"{subject} has an unsupported access-allowed entry"); + } + if ((known.AccessMask & tamperRights) == 0) + { + continue; + } + if (ace.SecurityIdentifier is null || !IsTrustedWriter(ace.SecurityIdentifier)) + { + throw new InvalidOperationException($"{subject} grants write access to an untrusted principal"); + } + } + } + + private static bool IsAccessAllowedAce(AceType type) => + type is AceType.AccessAllowed or + AceType.AccessAllowedCompound or + AceType.AccessAllowedObject or + AceType.AccessAllowedCallback or + AceType.AccessAllowedCallbackObject; + + private static bool IsTrustedWriter(SecurityIdentifier sid) => + sid.IsWellKnown(WellKnownSidType.LocalSystemSid) || + sid.IsWellKnown(WellKnownSidType.BuiltinAdministratorsSid) || + string.Equals( + sid.Value, + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", + StringComparison.Ordinal); + + internal static string ImagePath(SafeProcessHandle process) + { + int capacity = 260; + while (capacity <= 32_768) + { + StringBuilder path = new(capacity); + int length = capacity; + if (Native.QueryFullProcessImageName(process, 0, path, ref length)) + { + return path.ToString(); + } + if (Marshal.GetLastWin32Error() != 122) + { + throw new Win32Exception(); + } + capacity *= 2; + } + throw new InvalidOperationException("parent image path is too long"); + } + + private static long CreationTime(SafeProcessHandle process) + { + if (!Native.GetProcessTimes(process, out long created, out _, out _, out _)) + { + throw new Win32Exception(); + } + return DateTime.FromFileTimeUtc(created).Ticks; + } + + private static uint SessionId(int processId) + { + if (!Native.ProcessIdToSessionId(processId, out uint sessionId)) + { + throw new Win32Exception(); + } + return sessionId; + } + + internal static void EnsureActive(SafeProcessHandle process) + { + if (!Native.GetExitCodeProcess(process, out uint exitCode)) + { + throw new Win32Exception(); + } + if (exitCode != StillActive) + { + throw new InvalidOperationException("parent process exited"); + } + } +} + +internal sealed class BrokerServerLease : IDisposable +{ + private const string AgentExecutableName = "DevolutionsAgent.exe"; + + private readonly SafeProcessHandle process; + private readonly SafeFileHandle image; + private readonly List directories; + + private BrokerServerLease( + SafeProcessHandle process, + SafeFileHandle image, + List directories) + { + this.process = process; + this.image = image; + this.directories = directories; + } + + internal static BrokerServerLease Open(SafePipeHandle pipe) + { + if (!Native.GetNamedPipeServerProcessId(pipe, out int processId)) + { + throw new Win32Exception(); + } + + SafeProcessHandle process = Native.OpenProcess( + PeerLease.ProcessQueryInformation | + PeerLease.ProcessQueryLimitedInformation | + PeerLease.Synchronize, + false, + processId); + if (process.IsInvalid) + { + throw new Win32Exception(); + } + + try + { + if (!PeerLease.IsLocalSystemProcess(process)) + { + throw new InvalidOperationException("broker server is not running as LocalSystem"); + } + string helperPath = Environment.ProcessPath + ?? throw new InvalidOperationException("helper executable path is unavailable"); + string expectedPath = Path.Combine( + Path.GetDirectoryName(helperPath) + ?? throw new InvalidOperationException("helper installation directory is unavailable"), + AgentExecutableName); + string serverPath = PeerLease.ImagePath(process); + if (!IsExpectedPath(serverPath, expectedPath)) + { + throw new InvalidOperationException("broker server is not the installed Agent"); + } + + SafeFileHandle image = Native.CreateFile( + serverPath, + PeerLease.GenericRead | + PeerLease.FileExecute | + PeerLease.ReadControl | + PeerLease.Synchronize, + PeerLease.FileShareRead, + IntPtr.Zero, + PeerLease.OpenExisting, + PeerLease.FileFlagOpenReparsePoint, + IntPtr.Zero); + if (image.IsInvalid) + { + throw new Win32Exception(); + } + + try + { + PeerLease.VerifyImageMapping(process, image); + PeerLease.VerifyProtectedPath(image, "broker server image", PeerLease.FileTamperRights); + using X509Certificate2 signer = + PeerLease.VerifyAuthenticodeSigner(serverPath, image, "broker server"); + string thumbprint = signer.GetCertHashString(HashAlgorithmName.SHA1).ToLowerInvariant(); + if (!PeerLease.IsAllowedDevolutionsSigner(thumbprint)) + { + throw new InvalidOperationException("broker server signer is not authorized"); + } + List? directories = RetainProtectedDirectories( + Path.GetDirectoryName(expectedPath) + ?? throw new InvalidOperationException("Agent installation directory is unavailable")); + try + { + PeerLease.VerifyImageMapping(process, image); + PeerLease.EnsureActive(process); + if (!Native.GetNamedPipeServerProcessId(pipe, out int confirmedProcessId) || + confirmedProcessId != processId) + { + throw new InvalidOperationException("broker server process changed during authentication"); + } + return new BrokerServerLease(process, image, directories); + } + catch + { + foreach (SafeFileHandle directory in directories) + { + directory.Dispose(); + } + throw; + } + } + catch + { + image.Dispose(); + throw; + } + } + catch + { + process.Dispose(); + throw; + } + } + + internal static bool IsExpectedPath(string actual, string expected) => + string.Equals( + Path.GetFullPath(actual), + Path.GetFullPath(expected), + StringComparison.OrdinalIgnoreCase); + + public void Dispose() + { + foreach (SafeFileHandle directory in directories) + { + directory.Dispose(); + } + image.Dispose(); + process.Dispose(); + } + + private static List RetainProtectedDirectories(string installationDirectory) + { + List handles = []; + try + { + int tamperRights = PeerLease.ParentDirectoryTamperRights; + for (DirectoryInfo? directory = new(Path.GetFullPath(installationDirectory)); + directory is not null; + directory = directory.Parent) + { + SafeFileHandle handle = Native.CreateFile( + directory.FullName, + PeerLease.FileReadAttributes | PeerLease.ReadControl | PeerLease.Synchronize, + PeerLease.FileShareRead | PeerLease.FileShareWrite, + IntPtr.Zero, + PeerLease.OpenExisting, + PeerLease.FileFlagBackupSemantics | PeerLease.FileFlagOpenReparsePoint, + IntPtr.Zero); + if (handle.IsInvalid) + { + throw new Win32Exception(); + } + handles.Add(handle); + PeerLease.VerifyProtectedPath( + handle, + $"Agent installation directory '{directory.FullName}'", + tamperRights); + if (!Native.GetFileInformationByHandle(handle, out Native.ByHandleFileInformation information)) + { + throw new Win32Exception(); + } + if (!PeerLease.IsDirectory(information.FileAttributes)) + { + throw new InvalidOperationException( + $"Agent installation directory '{directory.FullName}' is not a directory"); + } + if (!IsExpectedPath(PeerLease.FinalPath(handle), directory.FullName)) + { + throw new InvalidOperationException( + $"Agent installation directory '{directory.FullName}' resolved to an unexpected path"); + } + tamperRights = PeerLease.AncestorDirectoryTamperRights; + } + return handles; + } + catch + { + foreach (SafeFileHandle handle in handles) + { + handle.Dispose(); + } + throw; + } + } +} + +internal static partial class Native +{ + internal const uint WtdRevokeWholeChain = 1; + internal const uint WtdRevocationCheckChain = 0x0000_0040; + internal const uint WtdCacheOnlyUrlRetrieval = 0x0000_1000; + internal const uint WtdDisableMd2Md4 = 0x0000_2000; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal readonly struct WinTrustFileInfo + { + internal readonly uint StructSize; + [MarshalAs(UnmanagedType.LPWStr)] + internal readonly string FilePath; + internal readonly IntPtr FileHandle; + internal readonly IntPtr KnownSubject; + + internal WinTrustFileInfo(string filePath, IntPtr fileHandle) + { + StructSize = checked((uint)Marshal.SizeOf()); + FilePath = filePath; + FileHandle = fileHandle; + KnownSubject = IntPtr.Zero; + } + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal struct WinTrustData + { + internal uint StructSize; + internal IntPtr PolicyCallbackData; + internal IntPtr SipClientData; + internal uint UiChoice; + internal uint RevocationChecks; + internal uint UnionChoice; + internal IntPtr FileInfo; + internal uint StateAction; + internal IntPtr StateData; + internal IntPtr UrlReference; + internal uint ProviderFlags; + internal uint UiContext; + internal IntPtr SignatureSettings; + + internal WinTrustData(IntPtr fileInfo) + { + StructSize = checked((uint)Marshal.SizeOf()); + PolicyCallbackData = IntPtr.Zero; + SipClientData = IntPtr.Zero; + UiChoice = 2; + RevocationChecks = WtdRevokeWholeChain; + UnionChoice = 1; + FileInfo = fileInfo; + StateAction = 1; + StateData = IntPtr.Zero; + UrlReference = IntPtr.Zero; + ProviderFlags = WtdRevocationCheckChain | WtdDisableMd2Md4; + UiContext = 0; + SignatureSettings = IntPtr.Zero; + } + } + + [StructLayout(LayoutKind.Sequential)] + internal readonly struct CryptProviderSigner + { + internal readonly uint StructSize; + internal readonly NativeFileTime VerifyAsOf; + internal readonly uint CertificateChainCount; + internal readonly IntPtr CertificateChain; + internal readonly uint SignerType; + internal readonly IntPtr SignerInfo; + internal readonly uint Error; + internal readonly uint CounterSignerCount; + internal readonly IntPtr CounterSigners; + internal readonly IntPtr ChainContext; + } + + [StructLayout(LayoutKind.Sequential)] + internal readonly struct NativeFileTime + { + internal readonly uint LowDateTime; + internal readonly uint HighDateTime; + } + + [StructLayout(LayoutKind.Sequential)] + internal readonly struct ByHandleFileInformation + { + internal readonly uint FileAttributes; + internal readonly NativeFileTime CreationTime; + internal readonly NativeFileTime LastAccessTime; + internal readonly NativeFileTime LastWriteTime; + internal readonly uint VolumeSerialNumber; + internal readonly uint FileSizeHigh; + internal readonly uint FileSizeLow; + internal readonly uint NumberOfLinks; + internal readonly uint FileIndexHigh; + internal readonly uint FileIndexLow; + } + + [StructLayout(LayoutKind.Sequential)] + internal readonly struct CryptProviderCertificate + { + internal readonly uint StructSize; + internal readonly IntPtr CertificateContext; + } + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool QueryFullProcessImageName( + SafeProcessHandle process, + uint flags, + [Out] StringBuilder path, + ref int size); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetProcessTimes( + SafeProcessHandle process, + out long creation, + out long exit, + out long kernel, + out long user); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool ProcessIdToSessionId(int processId, out uint sessionId); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetExitCodeProcess(SafeProcessHandle process, out uint exitCode); + + [LibraryImport("kernel32.dll", EntryPoint = "GetDriveTypeW", StringMarshalling = StringMarshalling.Utf16)] + internal static partial uint GetDriveType(string rootPathName); + + [DllImport("kernel32.dll", EntryPoint = "GetFinalPathNameByHandleW", SetLastError = true, CharSet = CharSet.Unicode)] + internal static extern uint GetFinalPathNameByHandle( + SafeFileHandle file, + StringBuilder path, + uint length, + uint flags); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetFileInformationByHandle( + SafeFileHandle file, + out ByHandleFileInformation information); + + [LibraryImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool OpenProcessToken( + SafeProcessHandle process, + uint desiredAccess, + out SafeAccessTokenHandle token); + + [LibraryImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetTokenInformation( + SafeAccessTokenHandle token, + int informationClass, + IntPtr information, + uint informationLength, + out uint returnLength); + + [LibraryImport("advapi32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool IsWellKnownSid(IntPtr sid, int wellKnownSidType); + + [LibraryImport("advapi32.dll", SetLastError = true)] + internal static partial uint GetSecurityInfo( + SafeFileHandle handle, + uint objectType, + uint securityInformation, + out IntPtr owner, + out IntPtr group, + out IntPtr dacl, + out IntPtr sacl, + out IntPtr securityDescriptor); + + [LibraryImport("advapi32.dll")] + internal static partial uint GetSecurityDescriptorLength(IntPtr securityDescriptor); + + [LibraryImport("kernel32.dll")] + internal static partial IntPtr LocalFree(IntPtr memory); + + [LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + internal static partial SafeFileHandle CreateFile( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial SafeProcessHandle OpenProcess(uint desiredAccess, [MarshalAs(UnmanagedType.Bool)] bool inherit, int processId); + + [LibraryImport("ntdll.dll")] + internal static partial int NtQueryInformationProcess( + IntPtr process, + int informationClass, + ref IntPtr information, + int informationLength, + out int returnLength); + + [LibraryImport("wintrust.dll", SetLastError = true)] + internal static partial int WinVerifyTrust(IntPtr window, ref Guid action, IntPtr data); + + [LibraryImport("wintrust.dll")] + internal static partial IntPtr WTHelperProvDataFromStateData(IntPtr stateData); + + [LibraryImport("wintrust.dll")] + internal static partial IntPtr WTHelperGetProvSignerFromChain( + IntPtr providerData, + uint signerIndex, + [MarshalAs(UnmanagedType.Bool)] bool counterSigner, + uint counterSignerIndex); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetNamedPipeServerProcessId(SafePipeHandle pipe, out int processId); +} diff --git a/package/AgentPolicyConsent/PolicyConsentContract.cs b/package/AgentPolicyConsent/PolicyConsentContract.cs new file mode 100644 index 000000000..5cba60549 --- /dev/null +++ b/package/AgentPolicyConsent/PolicyConsentContract.cs @@ -0,0 +1,26 @@ +namespace DevolutionsAgentPolicyConsent +{ + internal static class PolicyConsentContract + { + internal const string ProtocolVersion = "2.0"; + internal const string ExecutableName = "DevolutionsAgentPolicyConsent.exe"; + internal const string ProductName = "Devolutions Agent Policy Consent"; + internal const string DefaultBrokerPipeName = @"\\.\pipe\Devolutions.Now.PackageBroker.v1"; + + // Supported UniGetUI hosts are version 2026.2.7 or newer and must carry this signer: + // subject CN=Devolutions Inc, O=Devolutions Inc, C=CA; + // issuer CN=GlobalSign GCC R45 EV CodeSigning CA 2020, O=GlobalSign nv-sa, C=BE; + // serial 73D3C33603FF8BB44224F25E, SHA-1 8DB5A43BB8AFE4D2FFB92DA9007D8997A4CC4E13, + // valid 2023-10-30T17:51:18Z through 2026-10-30T17:51:18Z. + internal const string CurrentUiSignerSpkiSha256 = + "e43ed3368eaabff61abc79eb338cba9da88a80d93b751735ff417f26afa579a8"; + + // Keep synchronized with devolutions-agent-shared/src/windows/code_signing.rs. + internal static readonly string[] DevolutionsSignerSha1Thumbprints = + [ + "3f5202a9432d54293bdfe6f7e46adb0a6f8b3ba6", + "8db5a43bb8afe4d2ffb92da9007d8997a4cc4e13", + "50f753333811ff11f1920274afde3ffd4468b210", + ]; + } +} diff --git a/package/AgentPolicyConsent/Program.cs b/package/AgentPolicyConsent/Program.cs new file mode 100644 index 000000000..2ec726a4f --- /dev/null +++ b/package/AgentPolicyConsent/Program.cs @@ -0,0 +1,173 @@ +using System.IO.Pipes; +using System.Security.Principal; +using System.Text.Json; + +namespace DevolutionsAgentPolicyConsent; + +internal static class Program +{ + private const int Success = 0; + private const int InvalidArguments = 10; + private const int ConnectionFailure = 11; + private const int PeerAuthenticationFailure = 12; + private const int ProtocolFailure = 13; + private const int UnexpectedFailure = 14; + + private static async Task Main(string[] args) + { + if (!OperatingSystem.IsWindows()) + { + return InvalidArguments; + } + + Arguments arguments; + try + { + arguments = Protocol.ParseArguments(args); + } + catch (ProtocolException) + { + return InvalidArguments; + } + + using CancellationTokenSource handshake = new(Protocol.ConnectTimeout); + PeerLease peer; + try + { + peer = await OpenPeerAsync(arguments, handshake.Token); + } + catch (OperationCanceledException) + { + return ConnectionFailure; + } + catch + { + return PeerAuthenticationFailure; + } + + using (peer) + using (NamedPipeClientStream pipe = new( + ".", + arguments.PipeName, + PipeDirection.InOut, + PipeOptions.Asynchronous | PipeOptions.WriteThrough, + TokenImpersonationLevel.Anonymous)) + { + try + { + await pipe.ConnectAsync(handshake.Token); + if (!Native.GetNamedPipeServerProcessId(pipe.SafePipeHandle, out int serverProcessId)) + { + return PeerAuthenticationFailure; + } + peer.VerifyConnectedServer(serverProcessId); + byte[] body = await Protocol.ReadFrameAsync(pipe, Protocol.MaxRequestBodyBytes, handshake.Token); + ElevationRequest request = JsonSerializer.Deserialize(body, ProtocolJsonContext.Default.ElevationRequest) + ?? throw new ProtocolException("request is null"); + Protocol.ValidateRequest(request); + peer.VerifyConnectedServer(serverProcessId); + + using CancellationTokenSource exchange = new(Protocol.ExchangeTimeout); + using CancellationTokenSource brokerCancellation = + CancellationTokenSource.CreateLinkedTokenSource(exchange.Token); + using CancellationTokenSource monitorCancellation = + CancellationTokenSource.CreateLinkedTokenSource(exchange.Token); + Task monitor = MonitorHostAsync(pipe, brokerCancellation, monitorCancellation.Token); + ElevationResponse response = await BrokerClient.ReplaceAsync(request, brokerCancellation.Token); + monitorCancellation.Cancel(); + await IgnoreCancellationAsync(monitor); + Protocol.ValidateResponse(response); + + byte[] responseBody = JsonSerializer.SerializeToUtf8Bytes( + response, + ProtocolJsonContext.Default.ElevationResponse); + using CancellationTokenSource responseWrite = new(Protocol.ResponseWriteTimeout); + await Protocol.WriteFrameAsync( + pipe, + responseBody, + Protocol.MaxResponseBodyBytes, + responseWrite.Token); + return Success; + } + catch (OperationCanceledException) + { + return ConnectionFailure; + } + catch (IOException) + { + return ConnectionFailure; + } + catch (ProtocolException) + { + return ProtocolFailure; + } + catch (JsonException) + { + return ProtocolFailure; + } + catch + { + return UnexpectedFailure; + } + } + } + + private static async Task OpenPeerAsync(Arguments arguments, CancellationToken cancellationToken) + { + Task open = Task.Run(() => PeerLease.Open(arguments)); + try + { + return await open.WaitAsync(cancellationToken); + } + catch + { + _ = open.ContinueWith( + static completed => + { + if (completed.Status == TaskStatus.RanToCompletion) + { + completed.Result.Dispose(); + } + _ = completed.Exception; + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + throw; + } + } + + private static async Task MonitorHostAsync( + Stream pipe, + CancellationTokenSource brokerCancellation, + CancellationToken cancellationToken) + { + byte[] unexpected = new byte[1]; + try + { + int read = await pipe.ReadAsync(unexpected, cancellationToken); + if (read is 0 or 1) + { + brokerCancellation.Cancel(); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (IOException) + { + brokerCancellation.Cancel(); + } + } + + private static async Task IgnoreCancellationAsync(Task task) + { + try + { + await task; + } + catch (OperationCanceledException) + { + } + } +} diff --git a/package/AgentPolicyConsent/Protocol.cs b/package/AgentPolicyConsent/Protocol.cs new file mode 100644 index 000000000..e26288560 --- /dev/null +++ b/package/AgentPolicyConsent/Protocol.cs @@ -0,0 +1,241 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DevolutionsAgentPolicyConsent; + +internal static class Protocol +{ + internal const string Version = PolicyConsentContract.ProtocolVersion; + internal const string PipePrefix = "UniGetUI.PolicyElevation."; + internal const int MaxRequestBodyBytes = 16_793_054; + internal const int MaxResponseBodyBytes = 15_618; + internal static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(45); + internal static readonly TimeSpan ExchangeTimeout = TimeSpan.FromMinutes(2); + internal static readonly TimeSpan ResponseWriteTimeout = TimeSpan.FromSeconds(10); + + internal static Arguments ParseArguments(string[] args) + { + if (args.Length != 10) + { + throw new ProtocolException("invalid argument count"); + } + + Dictionary values = new(StringComparer.Ordinal); + for (int index = 0; index < args.Length; index += 2) + { + if (!values.TryAdd(args[index], args[index + 1])) + { + throw new ProtocolException("duplicate argument"); + } + } + + string protocol = Required(values, "--protocol"); + string pipe = Required(values, "--pipe"); + if (protocol != Version || + !pipe.StartsWith(PipePrefix, StringComparison.Ordinal) || + !IsLowerHex(pipe.AsSpan(PipePrefix.Length), 32) || + !int.TryParse( + Required(values, "--parent-pid"), + NumberStyles.None, + CultureInfo.InvariantCulture, + out int parentPid) || + parentPid <= 0 || + !long.TryParse( + Required(values, "--parent-created"), + NumberStyles.None, + CultureInfo.InvariantCulture, + out long parentCreated) || + parentCreated <= 0 || + !uint.TryParse( + Required(values, "--session"), + NumberStyles.None, + CultureInfo.InvariantCulture, + out uint session) || + values.Count != 5) + { + throw new ProtocolException("invalid argument value"); + } + + return new Arguments(pipe, parentPid, parentCreated, session); + } + + internal static void ValidateRequest(ElevationRequest request) + { + if (request.RequestId is null || + request.ProtocolVersion != Version || + !IsLowerHex(request.RequestId.AsSpan(), 32) || + request.Operation is not ("Update" or "ReplaceIdentity" or "Create" or "Repair") || + request.ConflictHandling is not ("Reject" or "ConfirmOverwrite") || + !IsCredential(request.ExpectedStoreToken, 512) || + !IsCredential(request.ValidationReceipt, 2048) || + request.Draft.ValueKind != JsonValueKind.Object) + { + throw new ProtocolException("invalid request"); + } + } + + internal static void ValidateResponse(ElevationResponse response) + { + if (response.RequestId is null || + response.ProtocolVersion != Version || + !IsLowerHex(response.RequestId.AsSpan(), 32) || + response.Disposition is not ("Committed" or "Rejected" or "Unknown") || + !IsOptionalCredential(response.BrokerErrorCode, 64)) + { + throw new ProtocolException("invalid response"); + } + + bool hasConflict = + response.ConflictStoreToken is not null || + response.ConflictState is not null || + response.ConflictPolicyId is not null; + switch (response.Disposition) + { + case "Committed" when + response.BrokerStatusCode is null && + response.BrokerErrorCode is null && + IsCredential(response.CommittedStoreToken, 512) && + !hasConflict: + return; + case "Rejected" when + response.CommittedStoreToken is null && + response.BrokerErrorCode is not null: + ValidateConflict(response, hasConflict); + return; + case "Unknown" when + response.CommittedStoreToken is null && + response.BrokerErrorCode is not null && + !hasConflict: + return; + default: + throw new ProtocolException("invalid response shape"); + } + } + + internal static async Task ReadFrameAsync(Stream stream, int maximum, CancellationToken cancellationToken) + { + byte[] header = new byte[4]; + await ReadExactlyAsync(stream, header, cancellationToken); + uint length = BinaryPrimitives.ReadUInt32BigEndian(header); + if (length == 0 || length > maximum) + { + throw new ProtocolException("invalid frame length"); + } + + byte[] body = GC.AllocateUninitializedArray(checked((int)length)); + await ReadExactlyAsync(stream, body, cancellationToken); + return body; + } + + internal static async Task WriteFrameAsync( + Stream stream, + ReadOnlyMemory body, + int maximum, + CancellationToken cancellationToken) + { + if (body.IsEmpty || body.Length > maximum) + { + throw new ProtocolException("invalid frame length"); + } + + byte[] header = new byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(header, checked((uint)body.Length)); + await stream.WriteAsync(header, cancellationToken); + await stream.WriteAsync(body, cancellationToken); + await stream.FlushAsync(cancellationToken); + } + + private static async Task ReadExactlyAsync(Stream stream, Memory buffer, CancellationToken cancellationToken) + { + int offset = 0; + while (offset < buffer.Length) + { + int read = await stream.ReadAsync(buffer[offset..], cancellationToken); + if (read == 0) + { + throw new EndOfStreamException("unexpected end of elevation frame"); + } + offset += read; + } + } + + private static string Required(Dictionary values, string key) => + values.TryGetValue(key, out string? value) && value.Length != 0 + ? value + : throw new ProtocolException("missing argument"); + + private static bool IsLowerHex(ReadOnlySpan value, int length) => + value.Length == length && value.IndexOfAnyExcept("0123456789abcdef") < 0; + + internal static bool IsCredential(string? value, int maximum) => + value is not null && + value.Length is > 0 && + value.Length <= maximum && + IsAsciiAlphaNumeric(value[0]) && + value.AsSpan(1).IndexOfAnyExcept("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._~:-") < 0; + + private static bool IsOptionalCredential(string? value, int maximum) => + value is null || IsCredential(value, maximum); + + private static void ValidateConflict(ElevationResponse response, bool hasConflict) + { + if (response.BrokerErrorCode != "StalePolicyStoreToken") + { + if (hasConflict) + { + throw new ProtocolException("non-stale response carries conflict fields"); + } + return; + } + + if (!IsCredential(response.ConflictStoreToken, 512) || + response.ConflictState is not ("Active" or "Missing" or "Invalid") || + (response.ConflictState == "Active" + ? !IsCredential(response.ConflictPolicyId, 2048) + : response.ConflictPolicyId is not null)) + { + throw new ProtocolException("invalid stale conflict"); + } + } + + private static bool IsAsciiAlphaNumeric(char value) => + value is >= '0' and <= '9' or >= 'A' and <= 'Z' or >= 'a' and <= 'z'; +} + +internal sealed record Arguments(string PipeName, int ParentProcessId, long ParentCreatedUtcTicks, uint SessionId); + +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +internal sealed record ElevationRequest( + [property: JsonRequired] string ProtocolVersion, + [property: JsonRequired] string RequestId, + [property: JsonRequired] string Operation, + [property: JsonRequired] string ConflictHandling, + [property: JsonRequired] string ExpectedStoreToken, + [property: JsonRequired] string ValidationReceipt, + [property: JsonRequired] JsonElement Draft); + +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +internal sealed record ElevationResponse( + [property: JsonRequired] string ProtocolVersion, + [property: JsonRequired] string RequestId, + [property: JsonRequired] string Disposition, + [property: JsonRequired] int? BrokerStatusCode, + [property: JsonRequired] string? BrokerErrorCode, + [property: JsonRequired] string? CommittedStoreToken, + [property: JsonRequired] string? ConflictStoreToken, + [property: JsonRequired] string? ConflictState, + [property: JsonRequired] string? ConflictPolicyId); + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = false, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(ElevationRequest))] +[JsonSerializable(typeof(ElevationResponse))] +internal sealed partial class ProtocolJsonContext : JsonSerializerContext; + +internal sealed class ProtocolException(string message) : Exception(message); diff --git a/package/AgentPolicyConsent/app.manifest b/package/AgentPolicyConsent/app.manifest new file mode 100644 index 000000000..d303ea813 --- /dev/null +++ b/package/AgentPolicyConsent/app.manifest @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj b/package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj new file mode 100644 index 000000000..e54f1240d --- /dev/null +++ b/package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj @@ -0,0 +1,21 @@ + + + net48 + latest + false + DevolutionsAgent.Installer.Tests + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/package/AgentWindowsManaged.Tests/EventLogSourceRegistryTests.cs b/package/AgentWindowsManaged.Tests/EventLogSourceRegistryTests.cs new file mode 100644 index 000000000..c6a05b87d --- /dev/null +++ b/package/AgentWindowsManaged.Tests/EventLogSourceRegistryTests.cs @@ -0,0 +1,33 @@ +using System; +using System.Reflection; + +using WixSharp; + +using Xunit; + +namespace DevolutionsAgent.Installer.Tests; + +public sealed class EventLogSourceRegistryTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SourceUsesNativeMsiRegistryLifecycle(bool win64) + { + Type program = System.Reflection.Assembly.Load("DevolutionsAgent").GetType("DevolutionsAgent.Program", throwOnError: true); + MethodInfo method = program.GetMethod( + "CreateEventLogSourceRegistryValue", + BindingFlags.Static | BindingFlags.NonPublic); + RegValue value = Assert.IsType(method.Invoke(null, [win64])); + + Assert.Equal(RegistryHive.LocalMachine, value.Root); + Assert.Equal(@"SYSTEM\CurrentControlSet\Services\EventLog\Application\Devolutions Agent", value.Key); + Assert.Equal("EventMessageFile", value.Name); + Assert.Equal("[INSTALLDIR]DevolutionsAgent.exe", value.Value); + Assert.Equal(win64, value.Win64); + Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, value.RegistryKeyAction); + Assert.False(value.ForceCreateOnInstall); + Assert.False(value.ForceDeleteOnUninstall); + Assert.Contains("Type=string", value.AttributesDefinition); + } +} diff --git a/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs b/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs new file mode 100644 index 000000000..81d027f3b --- /dev/null +++ b/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs @@ -0,0 +1,154 @@ +using System; +using System.Linq; +using System.Reflection; + +using WixSharp; + +using Xunit; + +namespace DevolutionsAgent.Installer.Tests; + +public sealed class PolicyConsentDiscoveryTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DiscoveryIsTransactionalAndArchitectureCorrect(bool win64) + { + RegValue value = CreateDiscoveryValue("ProtocolVersion", "2.0", win64); + + Assert.Equal(RegistryHive.LocalMachine, value.Root); + Assert.Equal(@"Software\Devolutions\Agent\PolicyConsentHelper", value.Key); + Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, value.RegistryKeyAction); + Assert.Equal(win64, value.Win64); + Assert.Equal( + win64 ? "Type=string; Component:Win64=yes" : "Type=string", + value.AttributesDefinition); + } + + [Theory] + [InlineData(Platform.x86, false)] + [InlineData(Platform.x64, true)] + [InlineData(Platform.arm64, true)] + public void DiscoveryUsesTheConsumerNativeRegistryView(Platform platform, bool expected) + { + Type program = System.Reflection.Assembly + .Load("DevolutionsAgent") + .GetType("DevolutionsAgent.Program", throwOnError: true); + MethodInfo method = program.GetMethod( + "Use64BitRegistryView", + BindingFlags.Static | BindingFlags.NonPublic); + + Assert.Equal(expected, Assert.IsType(method.Invoke(null, [platform]))); + } + + [Theory] + [InlineData(Platform.x86, 7)] + [InlineData(Platform.x64, 14)] + [InlineData(Platform.arm64, 14)] + public void AgentMsiPublishesDiscoveryInRequiredRegistryViews( + Platform platform, + int expectedCount) + { + Type program = System.Reflection.Assembly + .Load("DevolutionsAgent") + .GetType("DevolutionsAgent.Program", throwOnError: true); + MethodInfo method = program.GetMethod( + "CreatePolicyConsentRegistryValues", + BindingFlags.Static | BindingFlags.NonPublic, + binder: null, + types: [typeof(Platform?), typeof(Version)], + modifiers: null); + + RegValue[] values = Assert.IsAssignableFrom>( + method.Invoke(null, [platform, new Version(2026, 3, 0)])) + .ToArray(); + + Assert.Equal(expectedCount, values.Length); + Assert.All(values, value => + { + Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, value.RegistryKeyAction); + }); + Assert.Equal( + new[] + { + "ProtocolVersion", + "ExecutableName", + "ExecutablePath", + "ProductName", + "ProductVersion", + "BrokerPipeName", + "CurrentUiSignerSpkiSha256", + }, + values.Take(7).Select(value => value.Name)); + Assert.Equal( + new[] + { + "2.0", + "DevolutionsAgentPolicyConsent.exe", + "[INSTALLDIR]DevolutionsAgentPolicyConsent.exe", + "Devolutions Agent Policy Consent", + "2026.3.0", + @"\\.\pipe\Devolutions.Now.PackageBroker.v1", + "e43ed3368eaabff61abc79eb338cba9da88a80d93b751735ff417f26afa579a8", + }, + values.Take(7).Select(value => value.Value)); + Assert.All(values.Take(7), value => + { + Assert.Equal(platform != Platform.x86, value.Win64); + Assert.Equal( + platform == Platform.x86 ? "Type=string" : "Type=string; Component:Win64=yes", + value.AttributesDefinition); + }); + + if (platform == Platform.x86) + { + return; + } + + Assert.Equal(values.Take(7).Select(value => value.Name), values.Skip(7).Select(value => value.Name)); + Assert.Equal(values.Take(7).Select(value => value.Value), values.Skip(7).Select(value => value.Value)); + Assert.All(values.Skip(7), value => + { + Assert.False(value.Win64); + Assert.Equal("Type=string", value.AttributesDefinition); + }); + } + + [Fact] + public void DiscoveryPublishesTheFixedHelperIdentity() + { + RegValue executablePath = CreateDiscoveryValue( + "ExecutablePath", + "[INSTALLDIR]DevolutionsAgentPolicyConsent.exe", + true); + RegValue signer = CreateDiscoveryValue( + "CurrentUiSignerSpkiSha256", + "e43ed3368eaabff61abc79eb338cba9da88a80d93b751735ff417f26afa579a8", + true); + RegValue brokerPipe = CreateDiscoveryValue( + "BrokerPipeName", + @"\\.\pipe\Devolutions.Now.PackageBroker.v1", + true); + + Assert.Equal("[INSTALLDIR]DevolutionsAgentPolicyConsent.exe", executablePath.Value); + Assert.Equal( + "e43ed3368eaabff61abc79eb338cba9da88a80d93b751735ff417f26afa579a8", + signer.Value); + Assert.Equal(@"\\.\pipe\Devolutions.Now.PackageBroker.v1", brokerPipe.Value); + Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, executablePath.RegistryKeyAction); + Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, signer.RegistryKeyAction); + Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, brokerPipe.RegistryKeyAction); + } + + private static RegValue CreateDiscoveryValue(string name, string value, bool win64) + { + Type program = System.Reflection.Assembly + .Load("DevolutionsAgent") + .GetType("DevolutionsAgent.Program", throwOnError: true); + MethodInfo method = program.GetMethod( + "CreatePolicyConsentRegistryValue", + BindingFlags.Static | BindingFlags.NonPublic); + return Assert.IsType(method.Invoke(null, [name, value, win64])); + } +} diff --git a/package/AgentWindowsManaged/DevolutionsAgent.csproj b/package/AgentWindowsManaged/DevolutionsAgent.csproj index 527dd4bd4..6df8d0103 100644 --- a/package/AgentWindowsManaged/DevolutionsAgent.csproj +++ b/package/AgentWindowsManaged/DevolutionsAgent.csproj @@ -6,6 +6,7 @@ latest + diff --git a/package/AgentWindowsManaged/Program.cs b/package/AgentWindowsManaged/Program.cs index d2a246305..c48f23579 100644 --- a/package/AgentWindowsManaged/Program.cs +++ b/package/AgentWindowsManaged/Program.cs @@ -77,6 +77,10 @@ private static string ResolveArtifact(string varName, string defaultPath = null) private static string DevolutionsAgentExePath => ResolveArtifact("DAGENT_EXECUTABLE", "..\\..\\target\\debug\\devolutions-agent.exe"); + private static string DevolutionsAgentPolicyConsentPath => ResolveArtifact( + "DAGENT_POLICY_CONSENT_HELPER", + "..\\AgentPolicyConsent\\bin\\Release\\net10.0-windows\\win-x64\\publish\\DevolutionsAgentPolicyConsent.exe"); + private static string DevolutionsDesktopAgentPath { // ReSharper disable once ArrangeAccessorOwnerBody @@ -123,6 +127,12 @@ private static Version DevolutionsAgentVersion } } + // The MSI version drops the "20" prefix; discovery restores the calendar-year product version. + private static Version DevolutionsAgentProductVersion => new( + DevolutionsAgentVersion.Major + 2000, + DevolutionsAgentVersion.Minor, + DevolutionsAgentVersion.Build); + private static WixSharp.Platform TargetPlatform { get @@ -298,6 +308,10 @@ static void Main() new (Features.AGENT_FEATURE, DevolutionsMultiPwshExe) { TargetFileName = "multi-pwsh.exe" + }, + new (Features.AGENT_FEATURE, DevolutionsAgentPolicyConsentPath) + { + TargetFileName = Includes.POLICY_CONSENT_EXECUTABLE_NAME } }, Dirs = new[] @@ -348,8 +362,12 @@ static void Main() Win64 = project.Platform == Platform.x64, RegistryKeyAction = RegistryKeyAction.create, Feature = Features.PSU_FEATURE, - } + }, + CreateEventLogSourceRegistryValue(project.Platform == Platform.x64), }; + project.RegValues = project.RegValues + .Concat(CreatePolicyConsentRegistryValues(project.Platform, DevolutionsAgentProductVersion)) + .ToArray(); List projectProperties = AgentProperties.Properties.Select(x => x.ToWixSharpProperty()).ToList(); @@ -422,6 +440,85 @@ static void Main() } } + internal static RegValue CreateEventLogSourceRegistryValue(bool win64) => + new( + RegistryHive.LocalMachine, + $"SYSTEM\\CurrentControlSet\\Services\\EventLog\\Application\\{Includes.PRODUCT_NAME}", + "EventMessageFile", + $"[{AgentProperties.InstallDir}]{Includes.EXECUTABLE_NAME}") + { + AttributesDefinition = "Type=string", + Win64 = win64, + RegistryKeyAction = RegistryKeyAction.createAndRemoveOnUninstall, + }; + + internal static RegValue CreatePolicyConsentRegistryValue(string name, string value, bool win64) => + new( + RegistryHive.LocalMachine, + $"Software\\{Includes.VENDOR_NAME}\\{Includes.SHORT_NAME}\\PolicyConsentHelper", + name, + value) + { + AttributesDefinition = win64 ? "Type=string; Component:Win64=yes" : "Type=string", + Win64 = win64, + RegistryKeyAction = RegistryKeyAction.createAndRemoveOnUninstall, + Feature = Features.AGENT_FEATURE, + }; + + // 64-bit Agent MSIs publish discovery in both native and WOW6432Node views so a + // 32-bit UniGetUI consumer can discover the same protected helper. + internal static bool Use64BitRegistryView(Platform? platform) => + platform is Platform.x64 or Platform.arm64; + + internal static IEnumerable CreatePolicyConsentRegistryValues( + Platform? platform, + Version productVersion) + { + bool nativeRegistryViewIs64Bit = Use64BitRegistryView(platform); + IEnumerable nativeRegistryValues = CreatePolicyConsentRegistryValues( + productVersion, + nativeRegistryViewIs64Bit); + + if (!nativeRegistryViewIs64Bit) + { + return nativeRegistryValues; + } + + return nativeRegistryValues.Concat(CreatePolicyConsentRegistryValues(productVersion, false)); + } + + private static IEnumerable CreatePolicyConsentRegistryValues( + Version productVersion, + bool win64) => + CreatePolicyConsentDiscoveryValues(productVersion) + .Select(value => CreatePolicyConsentRegistryValue(value.Name, value.Value, win64)); + + private static IEnumerable<(string Name, string Value)> CreatePolicyConsentDiscoveryValues( + Version productVersion) => + [ + ( + "ProtocolVersion", + Includes.POLICY_CONSENT_PROTOCOL_VERSION), + ( + "ExecutableName", + Includes.POLICY_CONSENT_EXECUTABLE_NAME), + ( + "ExecutablePath", + $"[{AgentProperties.InstallDir}]{Includes.POLICY_CONSENT_EXECUTABLE_NAME}"), + ( + "ProductName", + Includes.POLICY_CONSENT_PRODUCT_NAME), + ( + "ProductVersion", + productVersion.ToString()), + ( + "BrokerPipeName", + Includes.POLICY_CONSENT_DEFAULT_BROKER_PIPE_NAME), + ( + "CurrentUiSignerSpkiSha256", + Includes.POLICY_CONSENT_CURRENT_UI_SIGNER_SPKI_SHA256), + ]; + private static void Project_UnhandledException(ExceptionEventArgs e) { string errorMessage = diff --git a/package/AgentWindowsManaged/Resources/Includes.cs b/package/AgentWindowsManaged/Resources/Includes.cs index 5ee9e12ae..0474decf9 100644 --- a/package/AgentWindowsManaged/Resources/Includes.cs +++ b/package/AgentWindowsManaged/Resources/Includes.cs @@ -18,6 +18,21 @@ internal static class Includes internal static readonly string EXECUTABLE_NAME = "DevolutionsAgent.exe"; + internal static readonly string POLICY_CONSENT_EXECUTABLE_NAME = + DevolutionsAgentPolicyConsent.PolicyConsentContract.ExecutableName; + + internal static readonly string POLICY_CONSENT_PRODUCT_NAME = + DevolutionsAgentPolicyConsent.PolicyConsentContract.ProductName; + + internal static readonly string POLICY_CONSENT_PROTOCOL_VERSION = + DevolutionsAgentPolicyConsent.PolicyConsentContract.ProtocolVersion; + + internal static readonly string POLICY_CONSENT_DEFAULT_BROKER_PIPE_NAME = + DevolutionsAgentPolicyConsent.PolicyConsentContract.DefaultBrokerPipeName; + + internal static readonly string POLICY_CONSENT_CURRENT_UI_SIGNER_SPKI_SHA256 = + DevolutionsAgentPolicyConsent.PolicyConsentContract.CurrentUiSignerSpkiSha256; + internal static readonly string DESKTOP_DIRECTORY_NAME = "desktop"; internal static readonly string DESKTOP_EXECUTABLE_NAME = "DevolutionsDesktopAgent.exe";