Browse Source

ci: run the tests as a basic user in both editions

Invoke-TestsAsBasicUser.ps1 derives a token of the SAFER level Normal User
from the token of the runner, as runas /trustlevel:0x20000 does, and runs
Invoke-Tests.ps1 with it in the same edition. The tests that skip in the
elevated session of the runner, because they need a session without the
privileges of an administrator, now run in CI as well. The contributor
page explains how to run the script locally.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: AI Assistant <ai@example.com>
ai/release-5.0.0-rc6
Raimund Andree 4 days ago
parent
commit
0c3324e915
  1. 206
      .github/scripts/Invoke-TestsAsBasicUser.ps1
  2. 12
      .github/workflows/ci.yml
  3. 13
      Docs/Contributing/02-Writing.md

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

12
.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

13
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.

Loading…
Cancel
Save