diff --git a/.github/scripts/Invoke-TestsAsBasicUser.ps1 b/.github/scripts/Invoke-TestsAsBasicUser.ps1 new file mode 100644 index 0000000..34f48f2 --- /dev/null +++ b/.github/scripts/Invoke-TestsAsBasicUser.ps1 @@ -0,0 +1,206 @@ +<# +.SYNOPSIS + Runs the Pester tests as a basic user, without the privileges and the Administrators group of the current account. + +.DESCRIPTION + Some tests need a session without the Security, Restore, or Create Symbolic Link privilege, and skip in an elevated + session such as that of a GitHub runner. This script derives a token of the SAFER level Normal User from the token + of the current process, as runas /trustlevel:0x20000 does: the Administrators group is deny-only, and only the + privilege to bypass traverse checking stays. It starts Invoke-Tests.ps1 in the same PowerShell edition with that + token, waits for it, prints its output, and fails if the tests failed. The job summary of GitHub Actions gets the + results through a file of this account, which the restricted token can write. + +.PARAMETER ResultPath + Specifies the path of the result file in the NUnit format. + +.PARAMETER Title + Specifies the heading of the test results in the job summary. + +.EXAMPLE + .\.github\scripts\Invoke-TestsAsBasicUser.ps1 -ResultPath TestResults\WindowsPowerShell-BasicUser.xml -Title 'Windows PowerShell 5.1 as a basic user' + + Runs the tests in Windows PowerShell 5.1 as a basic user. +#> +[CmdletBinding()] +param ( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $ResultPath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $Title +) + +$ErrorActionPreference = 'Stop' + +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +public static class NTFSSecurityBasicUserProcess +{ + private const uint SaferScopeIdUser = 2; + private const uint SaferLevelIdNormalUser = 0x20000; + private const uint SaferLevelOpen = 1; + private const uint CreateNoWindow = 0x08000000; + private const uint Infinite = 0xFFFFFFFF; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct StartupInfo + { + public int cb; + public string lpReserved; + public string lpDesktop; + public string lpTitle; + public int dwX; + public int dwY; + public int dwXSize; + public int dwYSize; + public int dwXCountChars; + public int dwYCountChars; + public int dwFillAttribute; + public int dwFlags; + public short wShowWindow; + public short cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInformation + { + public IntPtr hProcess; + public IntPtr hThread; + public int dwProcessId; + public int dwThreadId; + } + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool SaferCreateLevel(uint scopeId, uint levelId, uint openFlags, out IntPtr levelHandle, IntPtr reserved); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool SaferComputeTokenFromLevel(IntPtr levelHandle, IntPtr inAccessToken, out IntPtr outAccessToken, uint flags, IntPtr reserved); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool SaferCloseLevel(IntPtr levelHandle); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool CreateProcessAsUser(IntPtr token, string applicationName, string commandLine, IntPtr processAttributes, IntPtr threadAttributes, bool inheritHandles, uint creationFlags, IntPtr environment, string currentDirectory, ref StartupInfo startupInfo, out ProcessInformation processInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); + + // Starts the command line with a token of the SAFER level Normal User, waits for it, and returns its exit code. + public static int Run(string applicationName, string commandLine, string currentDirectory) + { + IntPtr level; + if (!SaferCreateLevel(SaferScopeIdUser, SaferLevelIdNormalUser, SaferLevelOpen, out level, IntPtr.Zero)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + IntPtr token = IntPtr.Zero; + try + { + if (!SaferComputeTokenFromLevel(level, IntPtr.Zero, out token, 0, IntPtr.Zero)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + var startupInfo = new StartupInfo(); + startupInfo.cb = Marshal.SizeOf(typeof(StartupInfo)); + ProcessInformation processInformation; + if (!CreateProcessAsUser(token, applicationName, commandLine, IntPtr.Zero, IntPtr.Zero, false, CreateNoWindow, IntPtr.Zero, currentDirectory, ref startupInfo, out processInformation)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + try + { + WaitForSingleObject(processInformation.hProcess, Infinite); + uint exitCode; + if (!GetExitCodeProcess(processInformation.hProcess, out exitCode)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + return (int)exitCode; + } + finally + { + CloseHandle(processInformation.hThread); + CloseHandle(processInformation.hProcess); + } + } + finally + { + if (token != IntPtr.Zero) + { + CloseHandle(token); + } + + SaferCloseLevel(level); + } + } +} +'@ + +$repositoryPath = (Resolve-Path -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath '..\..')).ProviderPath +$resultFullPath = [IO.Path]::GetFullPath((Join-Path -Path $repositoryPath -ChildPath $ResultPath)) +$resultFolder = Split-Path -Path $resultFullPath -Parent +if (-not (Test-Path -LiteralPath $resultFolder)) { + New-Item -ItemType Directory -Path $resultFolder | Out-Null +} + +$runFolder = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath ('NTFSSecurity.BasicUser-{0}' -f [guid]::NewGuid().ToString('N').Substring(0, 8)) +New-Item -ItemType Directory -Path $runFolder | Out-Null +$logPath = Join-Path -Path $runFolder -ChildPath 'Output.log' +$summaryPath = Join-Path -Path $runFolder -ChildPath 'Summary.md' +# In the temp folder of the account, which the restricted token can write, unlike maybe the folder of the repository +$runResultPath = Join-Path -Path $runFolder -ChildPath 'Result.xml' + +$executable = (Get-Process -Id $PID).Path +$testScript = Join-Path -Path $PSScriptRoot -ChildPath 'Invoke-Tests.ps1' +# cmd redirects the output of the tests, which have no console of their own. +$commandLine = 'cmd.exe /d /s /c ""{0}" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{1}" -ResultPath "{2}" -Title "{3}" > "{4}" 2>&1"' -f + $executable, $testScript, $runResultPath, $Title.Replace('"', "'"), $logPath + +$stepSummary = $env:GITHUB_STEP_SUMMARY +$env:GITHUB_STEP_SUMMARY = $summaryPath +try { + "Running the tests as a basic user: $commandLine" + $exitCode = [NTFSSecurityBasicUserProcess]::Run((Join-Path -Path $env:SystemRoot -ChildPath 'System32\cmd.exe'), $commandLine, $repositoryPath) +} +finally { + $env:GITHUB_STEP_SUMMARY = $stepSummary +} + +if (Test-Path -LiteralPath $logPath) { + Get-Content -LiteralPath $logPath +} + +if ($stepSummary -and (Test-Path -LiteralPath $summaryPath)) { + $encoding = New-Object -TypeName 'System.Text.UTF8Encoding' -ArgumentList $false + [IO.File]::AppendAllText($stepSummary, [IO.File]::ReadAllText($summaryPath), $encoding) +} + +if (Test-Path -LiteralPath $runResultPath) { + Copy-Item -LiteralPath $runResultPath -Destination $resultFullPath -Force +} + +Remove-Item -LiteralPath $runFolder -Recurse -Force +if ($exitCode -ne 0) { + throw "The tests as a basic user failed with exit code $exitCode." +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc25332..e2c63d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,6 +121,18 @@ jobs: shell: pwsh run: ./.github/scripts/Invoke-Tests.ps1 -ResultPath TestResults/PowerShell7.xml -Title 'PowerShell 7' + # Without the privileges and the Administrators group of the runner account, so the tests that an elevated + # session skips run as well + - name: Run the tests in Windows PowerShell 5.1 as a basic user + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + shell: powershell + run: .\.github\scripts\Invoke-TestsAsBasicUser.ps1 -ResultPath TestResults\WindowsPowerShell-BasicUser.xml -Title 'Windows PowerShell 5.1 as a basic user' + + - name: Run the tests in PowerShell 7 as a basic user + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + shell: pwsh + run: ./.github/scripts/Invoke-TestsAsBasicUser.ps1 -ResultPath TestResults/PowerShell7-BasicUser.xml -Title 'PowerShell 7 as a basic user' + - name: Upload the test results if: ${{ !cancelled() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/Docs/Contributing/02-Writing.md b/Docs/Contributing/02-Writing.md index 7da169a..8cec86f 100644 --- a/Docs/Contributing/02-Writing.md +++ b/Docs/Contributing/02-Writing.md @@ -119,7 +119,8 @@ Before you open a pull request, check the following: - The Pester tests in `Tests` pass. They test the module in `NTFSSecurity\bin\Release`, for example that `Get-Help` shows every page, and the conversion to the wiki. The CI workflow runs them in Windows - PowerShell 5.1 and in PowerShell 7 with Pester 5.7.1: + PowerShell 5.1 and in PowerShell 7 with Pester 5.7.1, in the elevated + session of the runner and again as a basic user: ```powershell Install-Module -Name Pester -RequiredVersion 5.7.1 -SkipPublisherCheck @@ -127,6 +128,16 @@ Before you open a pull request, check the following: Invoke-Pester -Path .\Tests -Output Detailed ``` + A test that needs a privilege skips without it, and a test that needs a + session without the privileges of an administrator skips in an elevated + session. To run the tests as a basic user from an elevated session, as the + CI workflow does, use `Invoke-TestsAsBasicUser.ps1`: + + ```powershell + .\.github\scripts\Invoke-TestsAsBasicUser.ps1 ` + -ResultPath TestResults\BasicUser.xml -Title 'As a basic user' + ``` + The [live tests](../../Tests/Lab/README.md) in `Tests\Lab` need a lab with a file server and domain accounts. Without one, they skip all their tests, and the CI workflow doesn't run them.