mirror of https://github.com/raandree/NTFSSecurity
Browse Source
Tests/TestHelpers.psm1 lets a test that changes files, links, ACLs, owners, audit entries, or inheritance work in its own sandbox below $env:TEMP\NTFSSecurity.Tests: - New-TestSandbox creates the folder; Assert-TestSandboxPath throws unless every target, relative ones resolved against the location, is inside it. - Remove-TestSandbox removes the links first without following them (Windows PowerShell 5.1 follows directory links when it removes a folder), resets ACL changes, and deletes the folder. - Test-IsElevated and Test-PrivilegeHeld decide which tests can run; CI runners are elevated, the workstation isn't. Tests/TestHelpers.Tests.ps1 (14 tests) covers the helpers. CI runs every *.Tests.ps1 file in Tests, so new test files need no workflow change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant <ai@example.com>pull/100/head
2 changed files with 282 additions and 0 deletions
@ -0,0 +1,119 @@ |
|||
<# |
|||
Tests the shared helpers in TestHelpers.psm1, which keep the tests that change files, links, and security |
|||
descriptors inside their sandbox folders. CI runs every *.Tests.ps1 file of this folder. |
|||
#> |
|||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute( |
|||
'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.' |
|||
)] |
|||
param () |
|||
|
|||
BeforeAll { |
|||
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force |
|||
} |
|||
|
|||
AfterAll { |
|||
Remove-Module -Name TestHelpers -Force -ErrorAction SilentlyContinue |
|||
} |
|||
|
|||
Describe 'Test helpers' { |
|||
Context 'New-TestSandbox' { |
|||
BeforeAll { |
|||
$sandbox = New-TestSandbox -Name 'Helpers' |
|||
} |
|||
|
|||
AfterAll { |
|||
Remove-TestSandbox -Sandbox $sandbox |
|||
} |
|||
|
|||
It 'Should create an empty folder below $env:TEMP\NTFSSecurity.Tests' { |
|||
$sandbox | Should -Exist |
|||
Get-ChildItem -LiteralPath $sandbox -Force | Should -BeNullOrEmpty |
|||
$expectedParent = [IO.Path]::GetFullPath((Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'NTFSSecurity.Tests')) |
|||
Split-Path -Path $sandbox -Parent | Should -Be $expectedParent |
|||
Split-Path -Path $sandbox -Leaf | Should -BeLike 'Helpers-*' |
|||
} |
|||
} |
|||
|
|||
Context 'Assert-TestSandboxPath' { |
|||
BeforeAll { |
|||
$sandbox = New-TestSandbox -Name 'Helpers' |
|||
Push-Location -LiteralPath $sandbox |
|||
} |
|||
|
|||
AfterAll { |
|||
Pop-Location |
|||
Remove-TestSandbox -Sandbox $sandbox |
|||
} |
|||
|
|||
It 'Should accept a full path inside the sandbox' { |
|||
{ Assert-TestSandboxPath -Sandbox $sandbox -Path (Join-Path -Path $sandbox -ChildPath 'Folder\File.txt') } | |
|||
Should -Not -Throw |
|||
} |
|||
|
|||
It 'Should resolve a relative path against the current location' { |
|||
{ Assert-TestSandboxPath -Sandbox $sandbox -Path '.\File.txt', 'Folder\File.txt' } | Should -Not -Throw |
|||
} |
|||
|
|||
It 'Should reject <_>' -ForEach @('..', '..\Other', 'C:\Windows', '\Windows') { |
|||
{ Assert-TestSandboxPath -Sandbox $sandbox -Path $_ } | Should -Throw -ExpectedMessage 'Refusing to change*' |
|||
} |
|||
|
|||
It 'Should reject a sibling folder whose name starts with the name of the sandbox' { |
|||
{ Assert-TestSandboxPath -Sandbox $sandbox -Path "$sandbox-Other\File.txt" } | |
|||
Should -Throw -ExpectedMessage 'Refusing to change*' |
|||
} |
|||
|
|||
It 'Should reject a sandbox that New-TestSandbox did not create' { |
|||
{ Assert-TestSandboxPath -Sandbox $env:TEMP -Path (Join-Path -Path $env:TEMP -ChildPath 'File.txt') } | |
|||
Should -Throw -ExpectedMessage '*is not a test sandbox*' |
|||
} |
|||
} |
|||
|
|||
Context 'Remove-TestSandbox' { |
|||
BeforeAll { |
|||
$sandbox = New-TestSandbox -Name 'Helpers' |
|||
$otherSandbox = New-TestSandbox -Name 'Helpers' |
|||
$target = Join-Path -Path $otherSandbox -ChildPath 'Target' |
|||
$link = Join-Path -Path $sandbox -ChildPath 'Link' |
|||
$locked = Join-Path -Path $sandbox -ChildPath 'Locked' |
|||
|
|||
Assert-TestSandboxPath -Sandbox $otherSandbox -Path $target |
|||
New-Item -ItemType Directory -Path $target | Out-Null |
|||
Set-Content -LiteralPath (Join-Path -Path $target -ChildPath 'Keep.txt') -Value 'Keep' |
|||
Assert-TestSandboxPath -Sandbox $sandbox -Path $link, $locked |
|||
New-Item -ItemType Junction -Path $link -Value $target | Out-Null |
|||
New-Item -ItemType Directory -Path $locked | Out-Null |
|||
Set-Content -LiteralPath (Join-Path -Path $locked -ChildPath 'File.txt') -Value 'Locked' |
|||
# An empty, protected DACL that grants nobody access, as some tests leave behind |
|||
& icacls.exe $locked /inheritance:r *> $null |
|||
|
|||
Remove-TestSandbox -Sandbox $sandbox |
|||
} |
|||
|
|||
AfterAll { |
|||
Remove-TestSandbox -Sandbox $otherSandbox |
|||
} |
|||
|
|||
It 'Should remove the sandbox, even with a folder that denies access' { |
|||
$sandbox | Should -Not -Exist |
|||
} |
|||
|
|||
It 'Should remove a junction without removing the files of its target' { |
|||
Join-Path -Path $target -ChildPath 'Keep.txt' | Should -Exist |
|||
} |
|||
} |
|||
|
|||
Context 'Test-IsElevated and Test-PrivilegeHeld' { |
|||
It 'Should tell whether the process is elevated' { |
|||
Test-IsElevated | Should -BeOfType [bool] |
|||
} |
|||
|
|||
It 'Should find SeChangeNotifyPrivilege, which every access token holds' { |
|||
Test-PrivilegeHeld -Name 'SeChangeNotifyPrivilege' | Should -BeTrue |
|||
} |
|||
|
|||
It 'Should not find a privilege that does not exist' { |
|||
Test-PrivilegeHeld -Name 'SeNoSuchPrivilege' | Should -BeFalse |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,163 @@ |
|||
<# |
|||
Shared helpers for the Pester tests. A test that changes files, links, ACLs, owners, audit entries, or |
|||
inheritance works in its own sandbox folder below $env:TEMP\NTFSSecurity.Tests: it creates the sandbox with |
|||
New-TestSandbox, sets the location to it, checks every target with Assert-TestSandboxPath before the change, |
|||
and removes the sandbox with Remove-TestSandbox. |
|||
#> |
|||
|
|||
$script:sandboxRoot = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'NTFSSecurity.Tests' |
|||
|
|||
function New-TestSandbox { |
|||
<# |
|||
.SYNOPSIS |
|||
Creates an empty sandbox folder for one test file or block and returns its full path. |
|||
#> |
|||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute( |
|||
'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Test helper that only creates sandboxes.' |
|||
)] |
|||
[CmdletBinding()] |
|||
[OutputType([string])] |
|||
param ( |
|||
[ValidatePattern('^[\w-]+$')] |
|||
[string] |
|||
$Name = 'Sandbox' |
|||
) |
|||
|
|||
$path = Join-Path -Path $script:sandboxRoot -ChildPath ('{0}-{1}' -f $Name, [guid]::NewGuid().ToString('N').Substring(0, 8)) |
|||
(New-Item -ItemType Directory -Path $path -Force).FullName |
|||
} |
|||
|
|||
function Assert-TestSandboxPath { |
|||
<# |
|||
.SYNOPSIS |
|||
Throws unless each path is inside the given sandbox, which itself must be a sandbox of New-TestSandbox. |
|||
Relative paths resolve against the current location. |
|||
#> |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] |
|||
[string] |
|||
$Sandbox, |
|||
|
|||
[Parameter(Mandatory, ValueFromPipeline)] |
|||
[string[]] |
|||
$Path |
|||
) |
|||
|
|||
begin { |
|||
$sandboxFullName = [IO.Path]::GetFullPath($Sandbox).TrimEnd('\') |
|||
$rootFullName = [IO.Path]::GetFullPath($script:sandboxRoot).TrimEnd('\') + '\' |
|||
if (-not $sandboxFullName.StartsWith($rootFullName, [StringComparison]::OrdinalIgnoreCase) -or |
|||
$sandboxFullName.Length -le $rootFullName.Length) { |
|||
throw "'$Sandbox' is not a test sandbox below '$rootFullName'." |
|||
} |
|||
$sandboxPrefix = $sandboxFullName + '\' |
|||
} |
|||
|
|||
process { |
|||
foreach ($item in $Path) { |
|||
$location = (Get-Location -PSProvider FileSystem).ProviderPath |
|||
$fullName = [IO.Path]::GetFullPath([IO.Path]::Combine($location, $item)).TrimEnd('\') |
|||
if (-not ($fullName + '\').StartsWith($sandboxPrefix, [StringComparison]::OrdinalIgnoreCase)) { |
|||
throw "Refusing to change '$fullName', which is outside the test sandbox '$sandboxFullName'." |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
function Remove-TestSandbox { |
|||
<# |
|||
.SYNOPSIS |
|||
Removes a sandbox of New-TestSandbox: first its links, without following them, then its ACL changes, then |
|||
the folder. |
|||
#> |
|||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute( |
|||
'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Test helper that only removes sandboxes.' |
|||
)] |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] |
|||
[string] |
|||
$Sandbox |
|||
) |
|||
|
|||
Assert-TestSandboxPath -Sandbox $Sandbox -Path $Sandbox |
|||
if (-not (Test-Path -LiteralPath $Sandbox)) { |
|||
return |
|||
} |
|||
|
|||
# Windows PowerShell 5.1 follows directory links when it removes a folder recursively, so the links go first. |
|||
& icacls.exe $Sandbox /reset /T /C /Q *> $null |
|||
$pending = New-Object -TypeName 'System.Collections.Generic.Stack[string]' |
|||
$pending.Push($Sandbox) |
|||
while ($pending.Count -gt 0) { |
|||
foreach ($entry in [IO.Directory]::GetFileSystemEntries($pending.Pop())) { |
|||
$attributes = [IO.File]::GetAttributes($entry) |
|||
if ($attributes -band [IO.FileAttributes]::ReparsePoint) { |
|||
if ($attributes -band [IO.FileAttributes]::Directory) { |
|||
[IO.Directory]::Delete($entry, $false) |
|||
} |
|||
else { |
|||
[IO.File]::Delete($entry) |
|||
} |
|||
} |
|||
elseif ($attributes -band [IO.FileAttributes]::Directory) { |
|||
$pending.Push($entry) |
|||
} |
|||
} |
|||
} |
|||
& icacls.exe $Sandbox /reset /T /C /Q *> $null |
|||
Get-ChildItem -LiteralPath $Sandbox -Recurse -Force | ForEach-Object -Process { |
|||
$_.Attributes = [IO.FileAttributes]::Normal |
|||
} |
|||
Remove-Item -LiteralPath $Sandbox -Recurse -Force |
|||
try { |
|||
# Fails while another sandbox exists, also one of a test run in parallel |
|||
[IO.Directory]::Delete($script:sandboxRoot, $false) |
|||
} |
|||
catch { |
|||
Write-Verbose -Message "Keeping '$script:sandboxRoot': $($_.Exception.Message)" |
|||
} |
|||
} |
|||
|
|||
function Test-IsElevated { |
|||
<# |
|||
.SYNOPSIS |
|||
Returns $true when the process runs elevated as an administrator. |
|||
#> |
|||
[CmdletBinding()] |
|||
[OutputType([bool])] |
|||
param () |
|||
|
|||
$principal = New-Object -TypeName 'Security.Principal.WindowsPrincipal' -ArgumentList ( |
|||
[Security.Principal.WindowsIdentity]::GetCurrent() |
|||
) |
|||
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) |
|||
} |
|||
|
|||
function Test-PrivilegeHeld { |
|||
<# |
|||
.SYNOPSIS |
|||
Returns $true when the access token of the process holds the privilege, enabled or not. |
|||
.PARAMETER Name |
|||
The privilege name, such as SeSecurityPrivilege or SeBackupPrivilege. |
|||
#> |
|||
[CmdletBinding()] |
|||
[OutputType([bool])] |
|||
param ( |
|||
[Parameter(Mandatory)] |
|||
[ValidatePattern('^Se\w+Privilege$')] |
|||
[string] |
|||
$Name |
|||
) |
|||
|
|||
# The column names are localized; the privilege names in the first column are not. |
|||
$output = & whoami.exe /priv /fo csv 2>$null |
|||
if ($LASTEXITCODE -ne 0) { |
|||
return $false |
|||
} |
|||
[bool] ($output | Select-Object -Skip 1 | ConvertFrom-Csv -Header 'Name', 'Description', 'State' | |
|||
Where-Object -Property Name -EQ -Value $Name) |
|||
} |
|||
|
|||
Export-ModuleMember -Function New-TestSandbox, Assert-TestSandboxPath, Remove-TestSandbox, Test-IsElevated, Test-PrivilegeHeld |
|||
Loading…
Reference in new issue