From 5b71ab5e83933d527435016e2aa9d238ea1bf69a Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Wed, 7 Oct 2026 16:21:21 +0000 Subject: [PATCH 1/4] test: add live tests in a lab Add Tests\Lab, which runs the module against a Windows file server with domain accounts in an AutomatedLab lab: #34 over SMB, the audit cmdlets over SMB, Get-NTFSEffectiveAccess with domain and file server groups, Get-NTFSOrphanedAccess with a deleted domain account, long paths on a share, and #108. Invoke-NTFSSecurityLabTest.ps1 prepares the lab and runs the tests per module version and PowerShell edition; without a lab, every live test skips. CI excludes the folder. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- .github/scripts/Invoke-Tests.ps1 | 7 +- Docs/Contributing/02-Writing.md | 4 + Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 | 1010 ++++++++++++++++++++++ Tests/Lab/NTFSSecurity.LabHelpers.ps1 | 238 +++++ Tests/Lab/NTFSSecurity.Live.Tests.ps1 | 468 ++++++++++ Tests/Lab/README.md | 124 +++ Tests/Lab/Start-NTFSSecurityLiveTest.ps1 | 85 ++ 7 files changed, 1934 insertions(+), 2 deletions(-) create mode 100644 Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 create mode 100644 Tests/Lab/NTFSSecurity.LabHelpers.ps1 create mode 100644 Tests/Lab/NTFSSecurity.Live.Tests.ps1 create mode 100644 Tests/Lab/README.md create mode 100644 Tests/Lab/Start-NTFSSecurityLiveTest.ps1 diff --git a/.github/scripts/Invoke-Tests.ps1 b/.github/scripts/Invoke-Tests.ps1 index 2fdc016..7c7d997 100644 --- a/.github/scripts/Invoke-Tests.ps1 +++ b/.github/scripts/Invoke-Tests.ps1 @@ -5,7 +5,7 @@ .DESCRIPTION Imports Pester 5.7.1, runs the tests against the module build in NTFSSecurity\bin\Release, writes the result file in the NUnit format, and adds the counts and the failed tests to the job summary of GitHub Actions. Fails if - a test or a test file fails. + a test or a test file fails. The live tests in Tests\Lab need a lab and don't run here. .PARAMETER ResultPath Specifies the path of the result file. @@ -39,8 +39,11 @@ if ($resultFolder -and -not (Test-Path -LiteralPath $resultFolder)) { New-Item -ItemType Directory -Path $resultFolder | Out-Null } +$testsPath = (Resolve-Path -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath '..\..\Tests')).ProviderPath $configuration = New-PesterConfiguration -$configuration.Run.Path = Join-Path -Path $PSScriptRoot -ChildPath '..\..\Tests' +$configuration.Run.Path = $testsPath +# The live tests in Tests\Lab need a lab (Tests\Lab\README.md). Pester matches the full path of each test file. +$configuration.Run.ExcludePath = '{0}\Lab\*' -f [WildcardPattern]::Escape($testsPath) $configuration.Run.PassThru = $true $configuration.Output.Verbosity = 'Detailed' $configuration.TestResult.Enabled = $true diff --git a/Docs/Contributing/02-Writing.md b/Docs/Contributing/02-Writing.md index 98c78a2..7da169a 100644 --- a/Docs/Contributing/02-Writing.md +++ b/Docs/Contributing/02-Writing.md @@ -127,6 +127,10 @@ Before you open a pull request, check the following: Invoke-Pester -Path .\Tests -Output Detailed ``` + 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. + - All links work. The CI workflow checks them with `Get-MarkdownLink` from the MarkdownLinkCheck module: diff --git a/Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 b/Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 new file mode 100644 index 0000000..c8adcad --- /dev/null +++ b/Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 @@ -0,0 +1,1010 @@ +<# +.SYNOPSIS + Runs the live tests of NTFSSecurity in an AutomatedLab lab against versions of the module. + +.DESCRIPTION + Prepares what NTFSSecurity.Live.Tests.ps1 needs in a lab with a domain controller, a file server, and a client of + one domain, and runs the tests for each module version and PowerShell edition: on the client as the accounts of the + roles Delegate, ServerAdmin, and Admin, then on the file server in the role Server. Each version runs in its own + process. README.md describes the cases; the results and a summary go to a new folder in OutputPath. + + The script changes only the lab: the organizational unit NTFSSecurityLive with the accounts and groups of the + tests, the local group NtfsLiveLocal and members of Administrators on the file server, members of Administrators + and Remote Management Users on the client, the share NTFSSecurityLive, and the folders C:\NTFSSecurityLive and + C:\NTFSSecurityLab. -RemoveFixture removes them again. The passwords of the accounts exist only in this process. + +.PARAMETER LabName + The name of the AutomatedLab lab. + +.PARAMETER DomainController + The domain controller that manages the accounts of the tests. + +.PARAMETER FileServer + The file server with the share. Its domain must be the domain of the domain controller. + +.PARAMETER Client + The computer that runs the tests. Its domain must be the domain of the domain controller. + +.PARAMETER Version + The versions of NTFSSecurity on the PowerShell Gallery to test, such as 5.0.0-rc4. + +.PARAMETER ModulePath + A folder with a build of the module, such as NTFSSecurity\bin\Release, to test as the version 'local'. + +.PARAMETER Edition + The PowerShell editions to test in: Desktop (Windows PowerShell 5.1) and Core (PowerShell 7). + +.PARAMETER OutputPath + The folder for the downloaded packages and the results. + +.PARAMETER RemoveFixture + Removes everything the script added to the lab, and runs no test. + +.EXAMPLE + .\Tests\Lab\Invoke-NTFSSecurityLabTest.ps1 -Version 5.0.0-rc2, 5.0.0-rc4 -Confirm:$false + + Runs the live tests against 5.0.0-rc2 and 5.0.0-rc4 in both PowerShell editions. + +.EXAMPLE + .\Tests\Lab\Invoke-NTFSSecurityLabTest.ps1 -RemoveFixture + + Removes the accounts, the share, and the folders of the live tests from the lab. + +.NOTES + Run it in an elevated Windows PowerShell 5.1 session on the Hyper-V host of the lab, with AutomatedLab. +#> +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High', DefaultParameterSetName = 'Test')] +param ( + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] + $LabName = 'WindowsAccessControlLab', + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] + $DomainController = 'F1ADC1', + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] + $FileServer = 'F1AFile2', + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] + $Client = 'F1AFile1', + + [Parameter(ParameterSetName = 'Test')] + [ValidatePattern('^\d+\.\d+\.\d+(-[0-9A-Za-z]+)?$')] + [string[]] + $Version = @('5.0.0-rc2', '5.0.0-rc4'), + + [Parameter(ParameterSetName = 'Test')] + [ValidateScript({ Test-Path -LiteralPath (Join-Path -Path $_ -ChildPath 'NTFSSecurity.psd1') -PathType Leaf })] + [string] + $ModulePath, + + [Parameter(ParameterSetName = 'Test')] + [ValidateSet('Desktop', 'Core')] + [string[]] + $Edition = @('Desktop', 'Core'), + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] + $OutputPath = (Join-Path -Path $env:TEMP -ChildPath 'NTFSSecurityLab'), + + [Parameter(Mandatory, ParameterSetName = 'Remove')] + [switch] + $RemoveFixture +) + +$ErrorActionPreference = 'Stop' + +$currentPrincipal = New-Object -TypeName 'System.Security.Principal.WindowsPrincipal' -ArgumentList ( + [System.Security.Principal.WindowsIdentity]::GetCurrent() +) +if (-not $currentPrincipal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'This script must run in an elevated (Run as Administrator) PowerShell session.' +} + +$shareName = 'NTFSSecurityLive' +$shareLocalPath = 'C:\NTFSSecurityLive' +$payloadPath = 'C:\NTFSSecurityLab' +$organizationalUnitName = 'NTFSSecurityLive' +$roleAccounts = [ordered]@{ + Delegate = 'NtfsLiveDelegate' + ServerAdmin = 'NtfsLiveServerAdmin' + Admin = 'NtfsLiveAdmin' +} +$subjectAccount = 'NtfsLiveSubject' +$orphanAccount = 'NtfsLiveOrphan' +$localGroupName = 'NtfsLiveLocal' +$groupMembers = @{ + NtfsLiveDelegates = @('NtfsLiveDelegate') + NtfsLiveInner = @('NtfsLiveSubject') + NtfsLiveOuter = @('NtfsLiveInner') +} +# A name that no DNS server resolves (RFC 2606) +$unreachableServerName = 'ntfssecurity-live.invalid' +# ReadAndExecute through the nested domain groups, Write through the local group of the file server, with Synchronize +$expectedFileServerRights = 0x1201BFL +$expectedClientRights = 0x1200A9L +$testFiles = 'NTFSSecurity.Live.Tests.ps1', 'NTFSSecurity.LabHelpers.ps1', 'Start-NTFSSecurityLiveTest.ps1' | + ForEach-Object -Process { Join-Path -Path $PSScriptRoot -ChildPath $_ } + +function Write-LabProgress { + param ([string] $Message) + + Write-Information -MessageData ('[{0:yyyy-MM-dd HH:mm:ss}] {1}' -f (Get-Date), $Message) -InformationAction Continue +} + +function New-LabPassword { + <# + .SYNOPSIS + Returns a random password that meets the complexity rules of a domain. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates a string in memory only.' + )] + [CmdletBinding()] + [OutputType([securestring])] + param () + + $bytes = New-Object -TypeName 'byte[]' -ArgumentList 24 + $generator = [System.Security.Cryptography.RandomNumberGenerator]::Create() + try { + $generator.GetBytes($bytes) + } + finally { + $generator.Dispose() + } + + # Base64 has upper case and lower case letters and digits; the suffix adds the other classes. + $password = New-Object -TypeName 'System.Security.SecureString' + foreach ($character in ([Convert]::ToBase64String($bytes) + '!a1Z').ToCharArray()) { + $password.AppendChar($character) + } + + $password.MakeReadOnly() + $password +} + +function Get-NTFSSecurityPackage { + <# + .SYNOPSIS + Downloads a version of NTFSSecurity from the PowerShell Gallery, checks its hash, and extracts the module. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [string] + $Version, + + [Parameter(Mandatory)] + [string] + $Destination + ) + + $entry = Invoke-RestMethod -Uri ("https://www.powershellgallery.com/api/v2/Packages(Id='NTFSSecurity',Version='{0}')" -f $Version) + $expectedHash = [string]$entry.entry.properties.PackageHash + if ($entry.entry.properties.PackageHashAlgorithm -ne 'SHA512' -or -not $expectedHash) { + throw "The PowerShell Gallery has no SHA512 hash for NTFSSecurity $Version." + } + + $package = Join-Path -Path $Destination -ChildPath "NTFSSecurity.$Version.nupkg" + if (-not (Test-Path -LiteralPath $package)) { + Invoke-WebRequest -Uri "https://www.powershellgallery.com/api/v2/package/NTFSSecurity/$Version" -OutFile $package -UseBasicParsing + } + + $sha512 = [System.Security.Cryptography.SHA512]::Create() + try { + $actualHash = [Convert]::ToBase64String($sha512.ComputeHash([System.IO.File]::ReadAllBytes($package))) + } + finally { + $sha512.Dispose() + } + + if ($actualHash -ne $expectedHash) { + Remove-Item -LiteralPath $package + throw "The package of NTFSSecurity $Version doesn't have the hash that the PowerShell Gallery publishes." + } + + # Expand-Archive of Windows PowerShell rejects the extension .nupkg. + $folder = Join-Path -Path $Destination -ChildPath $Version + $moduleFolder = Join-Path -Path $folder -ChildPath 'NTFSSecurity' + if (Test-Path -LiteralPath $folder) { + Remove-Item -LiteralPath $folder -Recurse -Force + } + + Add-Type -AssemblyName 'System.IO.Compression.FileSystem' + [System.IO.Compression.ZipFile]::ExtractToDirectory($package, $moduleFolder) + foreach ($name in '_rels', 'package', '[Content_Types].xml', 'NTFSSecurity.nuspec') { + $item = Join-Path -Path $moduleFolder -ChildPath $name + if (Test-Path -LiteralPath $item) { + Remove-Item -LiteralPath $item -Recurse -Force + } + } + + $moduleVersion = Get-LabModuleVersion -Path $moduleFolder + if ($moduleVersion -ne $Version) { + throw "The package of NTFSSecurity $Version contains the version $moduleVersion." + } + + [pscustomobject]@{ + Label = $Version + Folder = $folder + ModuleVersion = $moduleVersion + } +} + +function Get-LabModuleVersion { + <# + .SYNOPSIS + Returns the version of the module in a folder, with the prerelease label, from its manifest. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [string] + $Path + ) + + $manifest = Import-PowerShellDataFile -LiteralPath (Join-Path -Path $Path -ChildPath 'NTFSSecurity.psd1') + $moduleVersion = $manifest.ModuleVersion + if ($manifest.PrivateData.PSData.Prerelease) { + $moduleVersion = '{0}-{1}' -f $moduleVersion, $manifest.PrivateData.PSData.Prerelease + } + + $moduleVersion +} + +function ConvertFrom-LabTestResult { + <# + .SYNOPSIS + Returns the tests of a result file that Start-NTFSSecurityLiveTest.ps1 wrote, with their result and message. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [string] + $Json + ) + + # Windows PowerShell returns a JSON array as one object, which foreach enumerates. + $tests = ConvertFrom-Json -InputObject $Json + foreach ($test in $tests) { + [pscustomobject]@{ + Name = $test.Name + Result = $test.Result + Message = [string]$test.Message + } + } +} + +#region Remote script blocks +# Runs on the domain controller: creates or updates the accounts and groups in their organizational unit, pushes them +# to the other domain controllers of the domain, and returns their SIDs. +$accountScript = { + param ($OrganizationalUnitName, [hashtable] $Password, [hashtable] $GroupMember) + + $ErrorActionPreference = 'Stop' + Import-Module -Name ActiveDirectory + $domain = Get-ADDomain + $server = $domain.PDCEmulator + $path = 'OU={0},{1}' -f $OrganizationalUnitName, $domain.DistinguishedName + if (-not (Get-ADOrganizationalUnit -LDAPFilter "(ou=$OrganizationalUnitName)" -SearchBase $domain.DistinguishedName -SearchScope OneLevel -Server $server)) { + New-ADOrganizationalUnit -Name $OrganizationalUnitName -Path $domain.DistinguishedName -ProtectedFromAccidentalDeletion $false -Server $server + } + + foreach ($name in $Password.Keys) { + $user = Get-ADUser -LDAPFilter "(sAMAccountName=$name)" -Server $server + if ($user -and $user.DistinguishedName -notlike "*,$path") { + throw "The account '$name' exists outside '$path'." + } + + if ($user) { + Set-ADAccountPassword -Identity $user -Reset -NewPassword $Password[$name] -Server $server + Enable-ADAccount -Identity $user -Server $server + } + else { + New-ADUser -Name $name -SamAccountName $name -UserPrincipalName "$name@$($domain.DNSRoot)" -Path $path -AccountPassword $Password[$name] -Enabled $true -PasswordNeverExpires $true -Server $server + } + } + + # All groups exist before the memberships are set, because a group can be a member of another one. + $groups = @{} + foreach ($name in $GroupMember.Keys) { + $group = Get-ADGroup -LDAPFilter "(sAMAccountName=$name)" -Server $server + if ($group -and $group.DistinguishedName -notlike "*,$path") { + throw "The group '$name' exists outside '$path'." + } + + if (-not $group) { + $group = New-ADGroup -Name $name -SamAccountName $name -GroupScope Global -GroupCategory Security -Path $path -Server $server -PassThru + } + + $groups[$name] = $group + } + + foreach ($name in $GroupMember.Keys) { + $group = $groups[$name] + $current = @(Get-ADGroupMember -Identity $group -Server $server | ForEach-Object -Process { $_.SamAccountName }) + $wanted = @($GroupMember[$name]) + $missing = @($wanted | Where-Object -FilterScript { $_ -notin $current }) + $extra = @($current | Where-Object -FilterScript { $_ -notin $wanted }) + if ($missing) { + Add-ADGroupMember -Identity $group -Members $missing -Server $server + } + + if ($extra) { + Remove-ADGroupMember -Identity $group -Members $extra -Server $server -Confirm:$false + } + } + + # The other domain controllers replicate on their own schedule, and the computers of the domain may ask them. + $partners = @(Get-ADDomainController -Filter * -Server $server | Where-Object -FilterScript { $_.HostName -ne $server }) + foreach ($partner in $partners) { + foreach ($object in Get-ADObject -SearchBase $path -Filter * -Server $server) { + Sync-ADObject -Object $object.DistinguishedName -Source $server -Destination $partner.HostName + } + } + + $sids = @{} + foreach ($name in @($Password.Keys) + @($GroupMember.Keys)) { + $sids[$name] = (Get-ADObject -LDAPFilter "(sAMAccountName=$name)" -Properties objectSid -Server $server).objectSid.Value + } + + $current = [System.Security.Principal.WindowsIdentity]::GetCurrent() + [pscustomobject]@{ + DomainName = $domain.DNSRoot + NetBiosName = $domain.NetBIOSName + Sids = $sids + InstallName = $current.Name + InstallSid = $current.User.Value + } +} + +# Runs on the domain controller: creates the account whose entry becomes orphaned in a run. +$newOrphanScript = { + param ($OrganizationalUnitName, $Name, [securestring] $Password) + + $ErrorActionPreference = 'Stop' + Import-Module -Name ActiveDirectory + $domain = Get-ADDomain + $server = $domain.PDCEmulator + $path = 'OU={0},{1}' -f $OrganizationalUnitName, $domain.DistinguishedName + # A run that stopped early may have left the account. + Get-ADUser -LDAPFilter "(sAMAccountName=$Name)" -SearchBase $path -Server $server | Remove-ADUser -Server $server -Confirm:$false + $user = New-ADUser -Name $Name -SamAccountName $Name -Path $path -AccountPassword $Password -Enabled $false -Server $server -PassThru + [pscustomobject]@{ + Sid = $user.SID.Value + Guid = $user.ObjectGUID.Guid + } +} + +# Runs on the domain controller: deletes the account and waits until no domain controller of the domain knows it. +# Sync-ADObject can't push a deleted object, so the other domain controllers pull the domain partition. +$removeOrphanScript = { + param ($Guid) + + $ErrorActionPreference = 'Stop' + Import-Module -Name ActiveDirectory + $domain = Get-ADDomain + $server = $domain.PDCEmulator + Remove-ADUser -Identity $Guid -Server $server -Confirm:$false + foreach ($partner in @(Get-ADDomainController -Filter * -Server $server | Where-Object -FilterScript { $_.HostName -ne $server })) { + $deadline = (Get-Date).AddMinutes(3) + while (Get-ADObject -Filter "ObjectGUID -eq '$Guid'" -Server $partner.HostName) { + if ((Get-Date) -gt $deadline) { + throw "The domain controller $($partner.HostName) still knows the deleted account after 3 minutes." + } + + $null = & repadmin.exe /replicate $partner.HostName $server $domain.DistinguishedName 2>&1 + Start-Sleep -Seconds 10 + } + } +} + +# Runs on the file server once: the local group, the members of Administrators, the share, and the tools folder. +$fileServerSetupScript = { + param ($ShareName, $ShareLocalPath, $PayloadPath, $LocalGroupName, $SubjectSid, $AdministratorSid, $DelegatesSid) + + $ErrorActionPreference = 'Stop' + if (-not (Get-LocalGroup -Name $LocalGroupName -ErrorAction SilentlyContinue)) { + $null = New-LocalGroup -Name $LocalGroupName -Description 'NTFSSecurity live tests' + } + + if ($SubjectSid -notin @(Get-LocalGroupMember -Name $LocalGroupName | ForEach-Object -Process { $_.SID.Value })) { + Add-LocalGroupMember -Name $LocalGroupName -Member $SubjectSid + } + + foreach ($sid in $AdministratorSid) { + if ($sid -notin @(Get-LocalGroupMember -SID 'S-1-5-32-544' | ForEach-Object -Process { $_.SID.Value })) { + Add-LocalGroupMember -SID 'S-1-5-32-544' -Member $sid + } + } + + if (-not (Test-Path -LiteralPath $ShareLocalPath)) { + $null = New-Item -ItemType Directory -Path $ShareLocalPath + } + + # The folders of earlier runs, also those with paths longer than 260 characters, which PowerShell 7 removes. + $command = '$ErrorActionPreference = ''Stop''; Get-ChildItem -LiteralPath ''{0}'' -Force | Remove-Item -Recurse -Force' -f $ShareLocalPath + $output = & (Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe') -NoProfile -NonInteractive -EncodedCommand ([Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command))) 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "The folders of earlier runs could not be removed: $output" + } + + # Administrators and the system own the share; the delegated group may read it, and the folders of the cases grant + # what each case needs. + $administrators = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList 'S-1-5-32-544' + $acl = New-Object -TypeName 'System.Security.AccessControl.DirectorySecurity' + $acl.SetOwner($administrators) + $acl.SetAccessRuleProtection($true, $false) + foreach ($entry in @( + @{ Sid = 'S-1-5-32-544'; Rights = 'FullControl' } + @{ Sid = 'S-1-5-18'; Rights = 'FullControl' } + @{ Sid = $DelegatesSid; Rights = 'ReadAndExecute' } + )) { + $identity = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList $entry.Sid + $acl.AddAccessRule((New-Object -TypeName 'System.Security.AccessControl.FileSystemAccessRule' -ArgumentList $identity, $entry.Rights, 'ContainerInherit, ObjectInherit', 'None', 'Allow')) + } + + Set-Acl -LiteralPath $ShareLocalPath -AclObject $acl + if (-not (Get-SmbShare -Name $ShareName -ErrorAction SilentlyContinue)) { + $null = New-SmbShare -Name $ShareName -Path $ShareLocalPath -FullAccess 'Everyone' -Description 'NTFSSecurity live tests' + } + + foreach ($folder in 'Tests', 'Configuration') { + $null = New-Item -ItemType Directory -Path (Join-Path -Path $PayloadPath -ChildPath $folder) -Force + } + + (Get-LocalGroup -Name $LocalGroupName).SID.Value +} + +# Runs on the client once: the members of Administrators and Remote Management Users, and an empty payload folder. +$clientSetupScript = { + param ($PayloadPath, $AdministratorSid, $RemoteUserSid) + + $ErrorActionPreference = 'Stop' + foreach ($membership in @( + @{ Group = 'S-1-5-32-544'; Members = $AdministratorSid } + @{ Group = 'S-1-5-32-580'; Members = $RemoteUserSid } + )) { + foreach ($sid in $membership.Members) { + if ($sid -notin @(Get-LocalGroupMember -SID $membership.Group | ForEach-Object -Process { $_.SID.Value })) { + Add-LocalGroupMember -SID $membership.Group -Member $sid + } + } + } + + if (Test-Path -LiteralPath $PayloadPath) { + Remove-Item -LiteralPath $PayloadPath -Recurse -Force + } + + foreach ($folder in 'Modules', 'Tests', 'Configuration') { + $null = New-Item -ItemType Directory -Path (Join-Path -Path $PayloadPath -ChildPath $folder) -Force + } +} + +# Runs on the file server for each run: creates the folders of the cases below the share and returns what the +# configuration needs. Each case and operation gets its own folder, so that the tests don't depend on each other. +$fixtureScript = { + param ($HelperScript, $ShareLocalPath, $RunId, $Sid, $SubjectPrincipalName, $LongPathSegment) + + $ErrorActionPreference = 'Stop' + . ([scriptblock]::Create($HelperScript)) + $root = Join-Path -Path $ShareLocalPath -ChildPath $RunId + if (Test-Path -LiteralPath $root) { + throw "The folder '$root' exists already." + } + + $administrators = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList 'S-1-5-32-544' + + function New-AccessRule { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates an object in memory only.' + )] + param ([string] $Sid, [string] $Rights) + + $identity = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList $Sid + New-Object -TypeName 'System.Security.AccessControl.FileSystemAccessRule' -ArgumentList $identity, $Rights, 'ContainerInherit, ObjectInherit', 'None', 'Allow' + } + + function New-FixtureFolder { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Runs in the fixture that the script confirmed.' + )] + param ([string] $RelativePath, [object[]] $AccessRule = @(), [switch] $Protected, [switch] $RemoveInherited, + [switch] $Audit, [switch] $LegacyDacl) + + $path = Join-Path -Path $root -ChildPath $RelativePath + $null = New-Item -ItemType Directory -Path $path -Force + $acl = Get-Acl -LiteralPath $path + $acl.SetOwner($administrators) + if ($Protected) { + $acl.SetAccessRuleProtection($true, -not $RemoveInherited) + } + + foreach ($rule in $AccessRule) { + $acl.AddAccessRule($rule) + } + + Set-Acl -LiteralPath $path -AclObject $acl + if ($Audit) { + $auditAcl = Get-Acl -LiteralPath $path -Audit + $everyone = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList 'S-1-1-0' + $auditAcl.AddAuditRule((New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList $everyone, 'Delete', 'None', 'None', 'Success')) + Set-Acl -LiteralPath $path -AclObject $auditAcl + } + + # Set-Acl adds the auto-inherit flag, so the DACL is stored again without it at the end. + if ($LegacyDacl) { + Set-LabLegacyDacl -Path $path -Confirm:$false + } + + if ((Get-LabSecurityDescriptor -Path $path).Owner.Value -ne 'S-1-5-32-544') { + throw "Administrators don't own '$path'." + } + + if ((Test-LabDaclAutoInherited -Path $path) -eq [bool]$LegacyDacl) { + throw "The DACL of '$path' has the wrong auto-inherit flag." + } + + $path + } + + $null = New-Item -ItemType Directory -Path $root + $delegatesFullControl = New-AccessRule -Sid $Sid.NtfsLiveDelegates -Rights 'FullControl' + + # Case 1 (#34): the delegated group has Full Control on folders that Administrators own. + foreach ($variant in 'LegacyDacl', 'AutoInheritedDacl') { + foreach ($operation in 'AddAccess', 'RemoveAccess', 'ClearAccess', 'DisableInheritance', 'EnableInheritance', + 'SetInheritance', 'SetSecurityDescriptor') { + $rules = @($delegatesFullControl) + if ($operation -eq 'RemoveAccess') { + $rules += New-AccessRule -Sid 'S-1-1-0' -Rights 'ReadAndExecute' + } + + $null = New-FixtureFolder -RelativePath "Case1\$variant\$operation" -AccessRule $rules -Protected:($operation -eq 'EnableInheritance') -LegacyDacl:($variant -eq 'LegacyDacl') + } + } + + # Case 2: the same Full Control, and an audit entry on the folders whose entry the tests read or remove. + foreach ($auditRole in 'Admin', 'ServerAdmin', 'Delegate') { + $null = New-FixtureFolder -RelativePath "Case2\$auditRole\GetAudit" -AccessRule $delegatesFullControl -Audit + $null = New-FixtureFolder -RelativePath "Case2\$auditRole\AddAudit" -AccessRule $delegatesFullControl + $null = New-FixtureFolder -RelativePath "Case2\$auditRole\RemoveAudit" -AccessRule $delegatesFullControl -Audit + } + + # Case 3: only these entries, so that the expected rights are known. + $effectivePath = New-FixtureFolder -RelativePath 'Case3\EffectiveAccess' -Protected -RemoveInherited -AccessRule @( + New-AccessRule -Sid 'S-1-5-32-544' -Rights 'FullControl' + New-AccessRule -Sid 'S-1-5-18' -Rights 'FullControl' + New-AccessRule -Sid $Sid.NtfsLiveOuter -Rights 'ReadAndExecute' + New-AccessRule -Sid $Sid.LocalGroup -Rights 'Write' + ) + $effectiveDescriptor = Get-LabSecurityDescriptor -Path $effectivePath + $fileServerRights = Get-LabGrantedRight -Descriptor $effectiveDescriptor -Sid @(Get-LabTokenSid -UserPrincipalName $SubjectPrincipalName) + + # Case 4: an entry for the account that the domain controller deletes next, and a file that inherits it. + $orphanPath = New-FixtureFolder -RelativePath 'Case4\OrphanedAccess' -AccessRule (New-AccessRule -Sid $Sid.Orphan -Rights 'Modify') + Set-Content -LiteralPath (Join-Path -Path $orphanPath -ChildPath 'File.txt') -Value 'Orphan' + + # A file whose path on the share is longer than 260 characters, created by PowerShell 7, which handles such paths. + $longPath = New-FixtureFolder -RelativePath 'LongPath' + $longRelativePath = ($LongPathSegment -join '\') + '\File.txt' + $command = '$ErrorActionPreference = ''Stop''; $file = ''{0}''; $null = New-Item -ItemType Directory -Path (Split-Path -Path $file -Parent) -Force; Set-Content -LiteralPath $file -Value ''LongPath''' -f (Join-Path -Path $longPath -ChildPath $longRelativePath) + $output = & (Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe') -NoProfile -NonInteractive -EncodedCommand ([Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command))) 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "The long path could not be created: $output" + } + + $whatIfPath = New-FixtureFolder -RelativePath 'WhatIf' + Set-Content -LiteralPath (Join-Path -Path $whatIfPath -ChildPath 'Source.txt') -Value 'Source' -NoNewline + Set-Content -LiteralPath (Join-Path -Path $whatIfPath -ChildPath 'Destination.txt') -Value 'Destination' -NoNewline + + [pscustomobject]@{ + ServerPath = $root + FileServerRights = $fileServerRights + EffectiveAccessSddl = $effectiveDescriptor.GetSddlForm('All') + LongPath = $longRelativePath + } +} + +# Runs on the client for each run: the rights that the client's own token for the account grants on the folder of +# case 3, and whether the client still resolves the SID of the deleted account. +$clientOracleScript = { + param ($HelperScript, $EffectiveAccessSddl, $SubjectPrincipalName, $OrphanSid) + + $ErrorActionPreference = 'Stop' + . ([scriptblock]::Create($HelperScript)) + $descriptor = New-Object -TypeName 'System.Security.AccessControl.RawSecurityDescriptor' -ArgumentList $EffectiveAccessSddl + $orphanResolved = $true + try { + $null = (New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList $OrphanSid).Translate([System.Security.Principal.NTAccount]) + } + catch [System.Security.Principal.IdentityNotMappedException] { + $orphanResolved = $false + } + + [pscustomobject]@{ + ClientRights = Get-LabGrantedRight -Descriptor $descriptor -Sid @(Get-LabTokenSid -UserPrincipalName $SubjectPrincipalName) + OrphanResolved = $orphanResolved + } +} + +# Runs on the client and the file server: writes the configuration of a run. +$writeConfigurationScript = { + param ($Path, $Json) + + $ErrorActionPreference = 'Stop' + Set-Content -LiteralPath $Path -Value $Json -Encoding UTF8 +} + +# Runs on the client as the account of a role, or on the file server: runs the tests of the role in a new process. +$runScript = { + param ($Edition, $TestPath, $ModulePath, $ConfigurationPath, $Role) + + $executable = if ($Edition -eq 'Core') { + Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe' + } + else { + Join-Path -Path $env:SystemRoot -ChildPath 'System32\WindowsPowerShell\v1.0\powershell.exe' + } + + $resultPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ('NTFSSecurityLive-{0}.json' -f [guid]::NewGuid().ToString('N')) + $arguments = @( + '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass' + '-File', (Join-Path -Path $TestPath -ChildPath 'Start-NTFSSecurityLiveTest.ps1') + '-ConfigurationPath', $ConfigurationPath, '-Role', $Role, '-ResultPath', $resultPath + ) + if ($ModulePath) { + $arguments += '-ModulePath', $ModulePath + } + + $output = & $executable @arguments 2>&1 | ForEach-Object -Process { "$_" } + $exitCode = $LASTEXITCODE + $result = if (Test-Path -LiteralPath $resultPath) { Get-Content -LiteralPath $resultPath -Raw } + Remove-Item -LiteralPath $resultPath -ErrorAction SilentlyContinue + [pscustomobject]@{ + Account = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + ExitCode = $exitCode + Output = $output -join [Environment]::NewLine + Result = $result + } +} + +# Runs on the file server after the tests of a run: the stored owner, group, and DACL, and the SACL of each folder. +$stateScript = { + param ($HelperScript, $Root) + + $ErrorActionPreference = 'Stop' + . ([scriptblock]::Create($HelperScript)) + foreach ($folder in Get-ChildItem -LiteralPath $Root -Directory -Recurse -Depth 2) { + [pscustomobject]@{ + Path = $folder.FullName.Substring($Root.Length + 1) + Stored = (Get-LabSecurityDescriptor -Path $folder.FullName).GetSddlForm('All') + Sacl = (Get-Acl -LiteralPath $folder.FullName -Audit).GetSecurityDescriptorSddlForm('Audit') + } + } +} +# Runs on the domain controller: the SIDs of the accounts in the organizational unit, for -RemoveFixture. +$accountSidScript = { + param ($OrganizationalUnitName) + + $ErrorActionPreference = 'Stop' + Import-Module -Name ActiveDirectory + $domain = Get-ADDomain + $path = 'OU={0},{1}' -f $OrganizationalUnitName, $domain.DistinguishedName + if (Get-ADOrganizationalUnit -LDAPFilter "(ou=$OrganizationalUnitName)" -SearchBase $domain.DistinguishedName -SearchScope OneLevel -Server $domain.PDCEmulator) { + Get-ADUser -Filter * -SearchBase $path -Server $domain.PDCEmulator | ForEach-Object -Process { $_.SID.Value } + } +} + +$removeFileServerScript = { + param ($ShareName, $ShareLocalPath, $PayloadPath, $LocalGroupName, $Sid) + + $ErrorActionPreference = 'Stop' + if (Get-SmbShare -Name $ShareName -ErrorAction SilentlyContinue) { + Remove-SmbShare -Name $ShareName -Force + } + + foreach ($path in $ShareLocalPath, $PayloadPath) { + if (Test-Path -LiteralPath $path) { + $command = '$ErrorActionPreference = ''Stop''; Remove-Item -LiteralPath ''{0}'' -Recurse -Force' -f $path + $output = & (Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe') -NoProfile -NonInteractive -EncodedCommand ([Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command))) 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "'$path' could not be removed: $output" + } + } + } + + if (Get-LocalGroup -Name $LocalGroupName -ErrorAction SilentlyContinue) { + Remove-LocalGroup -Name $LocalGroupName + } + + foreach ($accountSid in $Sid) { + Remove-LocalGroupMember -SID 'S-1-5-32-544' -Member $accountSid -ErrorAction SilentlyContinue + } +} + +$removeClientScript = { + param ($PayloadPath, $Sid) + + $ErrorActionPreference = 'Stop' + foreach ($group in 'S-1-5-32-544', 'S-1-5-32-580') { + foreach ($accountSid in $Sid) { + Remove-LocalGroupMember -SID $group -Member $accountSid -ErrorAction SilentlyContinue + } + } + + Get-CimInstance -ClassName Win32_UserProfile | Where-Object -FilterScript { $_.SID -in $Sid } | Remove-CimInstance + if (Test-Path -LiteralPath $PayloadPath) { + Remove-Item -LiteralPath $PayloadPath -Recurse -Force + } +} + +$removeAccountScript = { + param ($OrganizationalUnitName) + + $ErrorActionPreference = 'Stop' + Import-Module -Name ActiveDirectory + $domain = Get-ADDomain + $unit = Get-ADOrganizationalUnit -LDAPFilter "(ou=$OrganizationalUnitName)" -SearchBase $domain.DistinguishedName -SearchScope OneLevel -Server $domain.PDCEmulator + if ($unit) { + Remove-ADOrganizationalUnit -Identity $unit -Recursive -Server $domain.PDCEmulator -Confirm:$false + } +} +#endregion Remote script blocks + +Import-Module -Name AutomatedLab -ErrorAction Stop +Import-Lab -Name $LabName -NoValidation -NoDisplay +$machines = foreach ($name in $DomainController, $FileServer, $Client) { + $machine = Get-LabVM -ComputerName $name + if (-not $machine) { + throw "The lab '$LabName' has no machine '$name'." + } + + $machine +} + +if (@($machines | ForEach-Object -Process { $_.DomainName } | Select-Object -Unique).Count -ne 1) { + throw 'The domain controller, the file server, and the client must belong to one domain.' +} + +$helperScript = Get-Content -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath 'NTFSSecurity.LabHelpers.ps1') -Raw +$labCommand = @{ + NoDisplay = $true + PassThru = $true + ErrorAction = 'Stop' +} + +if ($RemoveFixture) { + if (-not $PSCmdlet.ShouldProcess("lab '$LabName'", 'Remove the accounts, the share, and the folders of the live tests')) { + return + } + + $accountSids = @(Invoke-LabCommand -ComputerName $DomainController -ActivityName 'Read the accounts' -ScriptBlock $accountSidScript -ArgumentList $organizationalUnitName @labCommand) + $null = Invoke-LabCommand -ComputerName $FileServer -ActivityName 'Remove the share and the folders' -ScriptBlock $removeFileServerScript -ArgumentList $shareName, $shareLocalPath, $payloadPath, $localGroupName, $accountSids @labCommand + $null = Invoke-LabCommand -ComputerName $Client -ActivityName 'Remove the members and the folder' -ScriptBlock $removeClientScript -ArgumentList $payloadPath, $accountSids @labCommand + $null = Invoke-LabCommand -ComputerName $DomainController -ActivityName 'Remove the accounts' -ScriptBlock $removeAccountScript -ArgumentList $organizationalUnitName @labCommand + Write-LabProgress "Removed the live tests from the lab '$LabName'." + return +} + +$labels = @($Version) + @(if ($ModulePath) { 'local' }) +if (-not $PSCmdlet.ShouldProcess("lab '$LabName'", "Prepare the live tests and run them against $($labels -join ', ')")) { + return +} + +$resultFolder = Join-Path -Path $OutputPath -ChildPath ('Results\{0:yyyyMMdd-HHmmss}' -f (Get-Date)) +$packageFolder = Join-Path -Path $OutputPath -ChildPath 'Packages' +$null = New-Item -ItemType Directory -Path $resultFolder, $packageFolder -Force +Write-LabProgress "START live tests in the lab '$LabName' against $($labels -join ', ') in $($Edition -join ', '); results in $resultFolder" + +$modules = @( + foreach ($item in $Version) { + Get-NTFSSecurityPackage -Version $item -Destination $packageFolder + } + + if ($ModulePath) { + $folder = Join-Path -Path $packageFolder -ChildPath 'local' + if (Test-Path -LiteralPath $folder) { + Remove-Item -LiteralPath $folder -Recurse -Force + } + + $null = New-Item -ItemType Directory -Path $folder + Copy-Item -LiteralPath (Resolve-Path -LiteralPath $ModulePath).ProviderPath -Destination (Join-Path -Path $folder -ChildPath 'NTFSSecurity') -Recurse + [pscustomobject]@{ + Label = 'local' + Folder = $folder + ModuleVersion = Get-LabModuleVersion -Path (Join-Path -Path $folder -ChildPath 'NTFSSecurity') + } + } +) + +Write-LabProgress 'Preparing the accounts, the file server, and the client' +$passwords = @{} +foreach ($name in @($roleAccounts.Values) + $subjectAccount) { + $passwords[$name] = New-LabPassword +} + +$directory = Invoke-LabCommand -ComputerName $DomainController -ActivityName 'Prepare the accounts' -ScriptBlock $accountScript -ArgumentList $organizationalUnitName, $passwords, $groupMembers @labCommand +$sids = $directory.Sids +$localGroupSid = Invoke-LabCommand -ComputerName $FileServer -ActivityName 'Prepare the file server' -ScriptBlock $fileServerSetupScript -ArgumentList $shareName, $shareLocalPath, $payloadPath, $localGroupName, $sids[$subjectAccount], @($sids['NtfsLiveServerAdmin'], $sids['NtfsLiveAdmin']), $sids['NtfsLiveDelegates'] @labCommand +$null = Invoke-LabCommand -ComputerName $Client -ActivityName 'Prepare the client' -ScriptBlock $clientSetupScript -ArgumentList $payloadPath, @($sids['NtfsLiveDelegate'], $sids['NtfsLiveAdmin']), @($sids['NtfsLiveServerAdmin']) @labCommand +foreach ($computer in $Client, $FileServer) { + Copy-LabFileItem -Path $testFiles -ComputerName $computer -DestinationFolderPath (Join-Path -Path $payloadPath -ChildPath 'Tests') +} + +foreach ($module in $modules) { + Copy-LabFileItem -Path $module.Folder -ComputerName $Client -DestinationFolderPath (Join-Path -Path $payloadPath -ChildPath 'Modules') -Recurse +} + +$expectedFiles = @($modules | ForEach-Object -Process { Join-Path -Path $payloadPath -ChildPath ('Modules\{0}\NTFSSecurity\NTFSSecurity.psd1' -f $_.Label) }) + + @($testFiles | ForEach-Object -Process { Join-Path -Path $payloadPath -ChildPath ('Tests\{0}' -f (Split-Path -Path $_ -Leaf)) }) +$missingFiles = @(Invoke-LabCommand -ComputerName $Client -ActivityName 'Check the payload' -ScriptBlock { + param ($Path) + + $Path | Where-Object -FilterScript { -not (Test-Path -LiteralPath $_ -PathType Leaf) } + } -ArgumentList (, $expectedFiles) @labCommand) +if ($missingFiles.Count -gt 0) { + throw "The client lacks these files: $($missingFiles -join ', ')" +} + +$credentials = @{} +foreach ($role in $roleAccounts.Keys) { + $userName = '{0}\{1}' -f $directory.NetBiosName, $roleAccounts[$role] + $credentials[$role] = New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList $userName, $passwords[$roleAccounts[$role]] +} + +$clientAddress = (Get-LabVM -ComputerName $Client).IpV4Address +$fileServerFqdn = '{0}.{1}' -f $FileServer, $directory.DomainName +$subjectPrincipalName = '{0}@{1}' -f $subjectAccount, $directory.DomainName +$longPathSegments = 1..6 | ForEach-Object -Process { 'Segment{0:D2}-{1}' -f $_, ('x' * 40) } +$summary = New-Object -TypeName 'System.Collections.Generic.List[object]' + +foreach ($module in $modules) { + foreach ($runEdition in $Edition) { + $runId = '{0}-{1}-{2:yyyyMMddHHmmss}' -f $module.Label, $runEdition, (Get-Date) + Write-LabProgress "Run ${runId}: preparing the folders" + $orphan = Invoke-LabCommand -ComputerName $DomainController -ActivityName 'Create the orphan account' -ScriptBlock $newOrphanScript -ArgumentList $organizationalUnitName, $orphanAccount, (New-LabPassword) @labCommand + $fixtureSids = @{ + NtfsLiveDelegates = $sids['NtfsLiveDelegates'] + NtfsLiveOuter = $sids['NtfsLiveOuter'] + LocalGroup = [string]$localGroupSid + Orphan = $orphan.Sid + } + $fixture = Invoke-LabCommand -ComputerName $FileServer -ActivityName 'Create the folders of the run' -ScriptBlock $fixtureScript -ArgumentList $helperScript, $shareLocalPath, $runId, $fixtureSids, $subjectPrincipalName, $longPathSegments @labCommand + $null = Invoke-LabCommand -ComputerName $DomainController -ActivityName 'Delete the orphan account' -ScriptBlock $removeOrphanScript -ArgumentList $orphan.Guid @labCommand + $oracle = Invoke-LabCommand -ComputerName $Client -ActivityName 'Calculate the rights on the client' -ScriptBlock $clientOracleScript -ArgumentList $helperScript, $fixture.EffectiveAccessSddl, $subjectPrincipalName, $orphan.Sid @labCommand + if ($oracle.OrphanResolved) { + throw "The client still resolves the SID $($orphan.Sid) of the deleted account." + } + + if ($fixture.FileServerRights -ne $expectedFileServerRights -or $oracle.ClientRights -ne $expectedClientRights) { + throw ('The tokens of {0} grant 0x{1:X} on the file server and 0x{2:X} on the client instead of 0x{3:X} and 0x{4:X}.' -f + $subjectAccount, [long]$fixture.FileServerRights, [long]$oracle.ClientRights, $expectedFileServerRights, $expectedClientRights) + } + + $configuration = [ordered]@{ + RunId = $runId + ModuleVersion = $module.ModuleVersion + Edition = $runEdition + DomainName = $directory.DomainName + Client = $Client + FileServer = $FileServer + FileServerFqdn = $fileServerFqdn + ShareName = $shareName + ShareLocalPath = $shareLocalPath + SharePath = '\\{0}\{1}\{2}' -f $fileServerFqdn, $shareName, $runId + ServerPath = $fixture.ServerPath + UnreachableServerName = $unreachableServerName + LongPath = $fixture.LongPath + EffectiveAccess = [ordered]@{ + FileServerRights = [long]$fixture.FileServerRights + ClientRights = [long]$oracle.ClientRights + } + Accounts = [ordered]@{ + Delegate = [ordered]@{ Name = $credentials['Delegate'].UserName; Sid = $sids['NtfsLiveDelegate']; ClientAdministrator = $true; FileServerAdministrator = $false } + ServerAdmin = [ordered]@{ Name = $credentials['ServerAdmin'].UserName; Sid = $sids['NtfsLiveServerAdmin']; ClientAdministrator = $false; FileServerAdministrator = $true } + Admin = [ordered]@{ Name = $credentials['Admin'].UserName; Sid = $sids['NtfsLiveAdmin']; ClientAdministrator = $true; FileServerAdministrator = $true } + Server = [ordered]@{ Name = $directory.InstallName; Sid = $directory.InstallSid; ClientAdministrator = $true; FileServerAdministrator = $true } + Subject = [ordered]@{ Name = '{0}\{1}' -f $directory.NetBiosName, $subjectAccount; Sid = $sids[$subjectAccount] } + Orphan = [ordered]@{ Name = '{0}\{1}' -f $directory.NetBiosName, $orphanAccount; Sid = $orphan.Sid } + } + } + $json = $configuration | ConvertTo-Json -Depth 5 + Set-Content -LiteralPath (Join-Path -Path $resultFolder -ChildPath "$runId.json") -Value $json + $configurationPath = Join-Path -Path $payloadPath -ChildPath "Configuration\$runId.json" + foreach ($computer in $Client, $FileServer) { + $null = Invoke-LabCommand -ComputerName $computer -ActivityName 'Write the configuration' -ScriptBlock $writeConfigurationScript -ArgumentList $configurationPath, $json @labCommand + } + + $runs = @( + foreach ($role in $roleAccounts.Keys) { + @{ Role = $role; Edition = $runEdition; ModulePath = Join-Path -Path $payloadPath -ChildPath ('Modules\{0}\NTFSSecurity' -f $module.Label) } + } + @{ Role = 'Server'; Edition = 'Desktop'; ModulePath = '' } + ) + foreach ($run in $runs) { + Write-LabProgress "Run ${runId}: role $($run.Role)" + $arguments = $run.Edition, (Join-Path -Path $payloadPath -ChildPath 'Tests'), $run.ModulePath, $configurationPath, $run.Role + if ($run.Role -eq 'Server') { + $outcome = Invoke-LabCommand -ComputerName $FileServer -ActivityName 'Run the tests of the role Server' -ScriptBlock $runScript -ArgumentList $arguments @labCommand + } + else { + $session = New-PSSession -ComputerName $clientAddress -Credential $credentials[$run.Role] -Authentication Credssp + try { + $outcome = Invoke-Command -Session $session -ScriptBlock $runScript -ArgumentList $arguments + } + finally { + Remove-PSSession -Session $session + } + } + + $baseName = '{0}-{1}' -f $runId, $run.Role + Set-Content -LiteralPath (Join-Path -Path $resultFolder -ChildPath "$baseName.log") -Value $outcome.Output + $cases = @() + if ($outcome.Result) { + Set-Content -LiteralPath (Join-Path -Path $resultFolder -ChildPath "$baseName.result.json") -Value $outcome.Result + $cases = @(ConvertFrom-LabTestResult -Json $outcome.Result) + } + + $failures = @($cases | Where-Object -FilterScript { $_.Result -eq 'Failed' }) + $entry = [pscustomobject]@{ + Version = $module.Label + Edition = $runEdition + Role = $run.Role + Account = $outcome.Account + ExitCode = $outcome.ExitCode + Passed = @($cases | Where-Object -FilterScript { $_.Result -eq 'Passed' }).Count + Failed = $failures.Count + Skipped = @($cases | Where-Object -FilterScript { $_.Result -notin 'Passed', 'Failed' }).Count + Failures = @($failures | ForEach-Object -Process { [pscustomobject]@{ Name = $_.Name; Message = $_.Message } }) + } + $summary.Add($entry) + Write-LabProgress ('Run {0}: role {1} as {2}: {3} passed, {4} failed, {5} skipped, exit code {6}' -f + $runId, $run.Role, $entry.Account, $entry.Passed, $entry.Failed, $entry.Skipped, $entry.ExitCode) + } + + $state = Invoke-LabCommand -ComputerName $FileServer -ActivityName 'Read the security descriptors of the run' -ScriptBlock $stateScript -ArgumentList $helperScript, $fixture.ServerPath @labCommand + $state | Select-Object -Property Path, Stored, Sacl | ConvertTo-Json -Depth 3 | + Set-Content -LiteralPath (Join-Path -Path $resultFolder -ChildPath "$runId-State.json") + } +} + +$summary | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path -Path $resultFolder -ChildPath 'Summary.json') +$report = New-Object -TypeName 'System.Collections.Generic.List[string]' +$report.Add('# NTFSSecurity live tests') +$report.Add('') +$report.Add(('Lab `{0}`: client `{1}`, file server `{2}`, domain controller `{3}`.' -f $LabName, $Client, $FileServer, $DomainController)) +$report.Add('') +$report.Add('| Version | Edition | Role | Passed | Failed | Skipped |') +$report.Add('| --- | --- | --- | ---: | ---: | ---: |') +foreach ($entry in $summary) { + $report.Add(('| {0} | {1} | {2} | {3} | {4} | {5} |' -f $entry.Version, $entry.Edition, $entry.Role, $entry.Passed, $entry.Failed, $entry.Skipped)) +} + +foreach ($entry in $summary | Where-Object -FilterScript { $_.Failed -gt 0 -or -not $_.Passed }) { + $report.Add('') + $report.Add(('## {0}, {1}, {2}' -f $entry.Version, $entry.Edition, $entry.Role)) + $report.Add('') + foreach ($failure in $entry.Failures) { + $report.Add(('- {0}: {1}' -f $failure.Name, (($failure.Message -split '\r?\n')[0]))) + } + + if (-not $entry.Passed -and -not $entry.Failures) { + $report.Add('- No test ran; see the log of the role.') + } +} + +Set-Content -LiteralPath (Join-Path -Path $resultFolder -ChildPath 'Summary.md') -Value $report +Write-LabProgress "DONE live tests; results in $resultFolder" +$summary diff --git a/Tests/Lab/NTFSSecurity.LabHelpers.ps1 b/Tests/Lab/NTFSSecurity.LabHelpers.ps1 new file mode 100644 index 0000000..765ddd2 --- /dev/null +++ b/Tests/Lab/NTFSSecurity.LabHelpers.ps1 @@ -0,0 +1,238 @@ +<# + Helpers of the live tests in a lab (README.md). NTFSSecurity.Live.Tests.ps1 dot-sources this file on the client + and on the file server, and Invoke-NTFSSecurityLabTest.ps1 runs it on both to prepare the fixtures. Dot-sourcing + it only defines the helpers. +#> + +if (-not ('NTFSSecurityLab.NativeMethods' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace NTFSSecurityLab +{ + // GetFileSecurity and SetFileSecurity read and write a security descriptor as Windows stores it. + // GetNamedSecurityInfo and SetNamedSecurityInfo, which .NET and the module use, convert a DACL without the + // auto-inherit flag when they read it, and add the flag when they write a DACL. + public static class NativeMethods + { + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool GetFileSecurityW(string fileName, int requestedInformation, byte[] securityDescriptor, int length, out int lengthNeeded); + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool SetFileSecurityW(string fileName, int securityInformation, byte[] securityDescriptor); + + public static byte[] GetFileSecurity(string path, int information) + { + int needed; + GetFileSecurityW(path, information, null, 0, out needed); + if (needed == 0) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + var buffer = new byte[needed]; + if (!GetFileSecurityW(path, information, buffer, buffer.Length, out needed)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + return buffer; + } + + public static void SetFileSecurity(string path, int information, byte[] securityDescriptor) + { + if (!SetFileSecurityW(path, information, securityDescriptor)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + } +} +'@ +} + +function Get-LabSecurityDescriptor { + <# + .SYNOPSIS + Returns the owner, the group, and the DACL of a file or folder as Windows stores them. + .DESCRIPTION + GetNamedSecurityInfo returns the owner with a DACL without the auto-inherit flag even when only the DACL is + read, and converts such a DACL. This function reads with GetFileSecurity, which does neither. + .PARAMETER Path + A local path or a UNC path. The account needs the right to read the permissions. + #> + [CmdletBinding()] + [OutputType([System.Security.AccessControl.RawSecurityDescriptor])] + param ( + [Parameter(Mandatory)] + [string] + $Path + ) + + # OWNER_SECURITY_INFORMATION, GROUP_SECURITY_INFORMATION, and DACL_SECURITY_INFORMATION + $bytes = [NTFSSecurityLab.NativeMethods]::GetFileSecurity($Path, 7) + New-Object -TypeName 'System.Security.AccessControl.RawSecurityDescriptor' -ArgumentList $bytes, 0 +} + +function Test-LabDaclAutoInherited { + <# + .SYNOPSIS + Returns whether the stored DACL of a file or folder has the auto-inherit flag. + .PARAMETER Path + A local path or a UNC path. + #> + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory)] + [string] + $Path + ) + + $autoInherited = [System.Security.AccessControl.ControlFlags]::DiscretionaryAclAutoInherited + ((Get-LabSecurityDescriptor -Path $Path).ControlFlags -band $autoInherited) -ne 0 +} + +function Set-LabLegacyDacl { + <# + .SYNOPSIS + Stores the DACL of a file or folder again, without the auto-inherit flag. + .DESCRIPTION + Windows adds the flag whenever SetNamedSecurityInfo writes a DACL. SetFileSecurity stores the DACL as given, + like tools that predate Windows 2000. For such a DACL, GetNamedSecurityInfo returns the owner also when only + the DACL is read, and NTFSSecurity before 5.0.0-rc3 wrote that owner back (#34). + .PARAMETER Path + A local path. The account needs the right to change the permissions. + #> + [CmdletBinding(SupportsShouldProcess)] + param ( + [Parameter(Mandatory)] + [string] + $Path + ) + + $descriptor = Get-LabSecurityDescriptor -Path $Path + $autoInherited = [System.Security.AccessControl.ControlFlags]::DiscretionaryAclAutoInherited + $descriptor.SetFlags([System.Security.AccessControl.ControlFlags]($descriptor.ControlFlags -band -bnot $autoInherited)) + $bytes = New-Object -TypeName 'byte[]' -ArgumentList $descriptor.BinaryLength + $descriptor.GetBinaryForm($bytes, 0) + if ($PSCmdlet.ShouldProcess($Path, 'Store the DACL without the auto-inherit flag')) { + # DACL_SECURITY_INFORMATION + [NTFSSecurityLab.NativeMethods]::SetFileSecurity($Path, 4, $bytes) + } +} + +function Get-LabTokenSid { + <# + .SYNOPSIS + Returns the SIDs of the token that this computer creates for a domain account. + .DESCRIPTION + Logs the account on with Kerberos S4U, without its password, like the Effective Access tab of the advanced + security settings does. The token holds the account and all its groups that this computer knows: nested + domain groups and the local groups of this computer. + .PARAMETER UserPrincipalName + The user principal name of the account, such as user@contoso.com. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [string] + $UserPrincipalName + ) + + $identity = New-Object -TypeName 'System.Security.Principal.WindowsIdentity' -ArgumentList $UserPrincipalName + try { + $identity.User.Value + foreach ($group in $identity.Groups) { + $group.Value + } + } + finally { + $identity.Dispose() + } +} + +function Get-LabGrantedRight { + <# + .SYNOPSIS + Returns the access mask that the allow entries of a DACL grant to a set of SIDs. + .DESCRIPTION + Combines the entries that apply to the item itself and name one of the SIDs. A deny entry for one of the SIDs + makes the function throw, because the calculation doesn't cover it. + .PARAMETER Descriptor + The security descriptor of the item. + .PARAMETER Sid + The SIDs of a token, such as the output of Get-LabTokenSid. + #> + [CmdletBinding()] + [OutputType([long])] + param ( + [Parameter(Mandatory)] + [System.Security.AccessControl.RawSecurityDescriptor] + $Descriptor, + + [Parameter(Mandatory)] + [string[]] + $Sid + ) + + $granted = 0L + foreach ($ace in $Descriptor.DiscretionaryAcl) { + if ($ace -isnot [System.Security.AccessControl.CommonAce] -or $ace.SecurityIdentifier.Value -notin $Sid) { + continue + } + + # HasFlag, because Windows PowerShell can't apply -band to an enum of the type byte. + if ($ace.AceFlags.HasFlag([System.Security.AccessControl.AceFlags]::InheritOnly)) { + continue + } + + if ($ace.AceQualifier -ne [System.Security.AccessControl.AceQualifier]::AccessAllowed) { + throw "The DACL denies $($ace.SecurityIdentifier) access, which Get-LabGrantedRight doesn't calculate." + } + + $granted = $granted -bor ([long]$ace.AccessMask -band 0xFFFFFFFFL) + } + + $granted +} + +function Assert-LabTestTarget { + <# + .SYNOPSIS + Throws unless this process runs on the client or the file server that a configuration of the lab names, and + the folder of the run lies in the share of the lab. + .DESCRIPTION + The live tests change security descriptors and must never run on another computer, such as the host of the + lab or a workstation. + .PARAMETER Configuration + The configuration of the run that Invoke-NTFSSecurityLabTest.ps1 wrote. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [object] + $Configuration + ) + + # Without CIM, which an account that isn't an administrator can't use in a remote session + $domainName = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().DomainName + if ($domainName -ne $Configuration.DomainName) { + throw "The live tests run only on a computer of the lab domain '$($Configuration.DomainName)'." + } + + if ($env:COMPUTERNAME -notin $Configuration.Client, $Configuration.FileServer) { + throw "The live tests run only on '$($Configuration.Client)' and '$($Configuration.FileServer)', not on '$env:COMPUTERNAME'." + } + + $shareRoot = '\\{0}\{1}\' -f $Configuration.FileServerFqdn, $Configuration.ShareName + $comparison = [System.StringComparison]::OrdinalIgnoreCase + if (-not $Configuration.SharePath.StartsWith($shareRoot, $comparison) -or + -not $Configuration.ServerPath.StartsWith($Configuration.ShareLocalPath + '\', $comparison) -or + $Configuration.SharePath.Contains('..') -or $Configuration.ServerPath.Contains('..')) { + throw "The folder of the run must lie in the share '$shareRoot' of the lab." + } +} diff --git a/Tests/Lab/NTFSSecurity.Live.Tests.ps1 b/Tests/Lab/NTFSSecurity.Live.Tests.ps1 new file mode 100644 index 0000000..7037d7e --- /dev/null +++ b/Tests/Lab/NTFSSecurity.Live.Tests.ps1 @@ -0,0 +1,468 @@ +<# + Live tests of the module against a Windows file server in a lab, for the cases that depend on the file server or + on domain accounts and that the tests in the Tests folder can't cover. Invoke-NTFSSecurityLabTest.ps1 prepares the + lab and runs this file on the client in the roles Delegate, ServerAdmin, and Admin, as the accounts of these roles, + and then on the file server in the role Server, which checks the security descriptors that the runs on the client + left, without the module. README.md describes the cases and the lab. Without a configuration, all tests are + skipped. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', Justification = 'Pester passes the data of the container to the blocks.' +)] +param ( + [string] + $ModulePath, + + [string] + $ConfigurationPath, + + [ValidateSet('', 'Delegate', 'ServerAdmin', 'Admin', 'Server')] + [string] + $Role +) + +BeforeDiscovery { + $configured = -not [string]::IsNullOrEmpty($ConfigurationPath) + + $variants = @( + @{ Variant = 'LegacyDacl'; Description = 'a DACL without the auto-inherit flag'; AutoInherited = $false } + @{ Variant = 'AutoInheritedDacl'; Description = 'an auto-inherited DACL'; AutoInherited = $true } + ) + $operations = 'AddAccess', 'RemoveAccess', 'ClearAccess', 'DisableInheritance', 'EnableInheritance', + 'SetInheritance', 'SetSecurityDescriptor' + + $auditSuccessCases = @( + @{ AuditRole = 'Admin'; Description = 'an administrator of the file server and the client' } + @{ AuditRole = 'ServerAdmin'; Description = 'an administrator of the file server only' } + ) + + $ownedFolders = @( + foreach ($variant in $variants) { + foreach ($operation in $operations) { + @{ Folder = 'Case1\{0}\{1}' -f $variant.Variant, $operation } + } + } + foreach ($auditRole in 'Admin', 'ServerAdmin', 'Delegate') { + foreach ($operation in 'GetAudit', 'AddAudit', 'RemoveAudit') { + @{ Folder = 'Case2\{0}\{1}' -f $auditRole, $operation } + } + } + ) + + # The administrators of the file server add and remove the audit entries; the delegated account changes nothing. + $auditExpectations = @( + foreach ($auditRole in 'Admin', 'ServerAdmin', 'Delegate') { + $mayWrite = $auditRole -ne 'Delegate' + @{ Folder = "Case2\$auditRole\GetAudit"; Count = 1 } + @{ Folder = "Case2\$auditRole\AddAudit"; Count = [int]$mayWrite } + @{ Folder = "Case2\$auditRole\RemoveAudit"; Count = [int](-not $mayWrite) } + } + ) +} + +BeforeAll { + if ($ConfigurationPath) { + . (Join-Path -Path $PSScriptRoot -ChildPath 'NTFSSecurity.LabHelpers.ps1') + $configuration = Get-Content -LiteralPath $ConfigurationPath -Raw | ConvertFrom-Json + Assert-LabTestTarget -Configuration $configuration + + # On the client, the tests use the share; on the file server, the folder of the share. + $runRoot = if ($Role -eq 'Server') { $configuration.ServerPath } else { $configuration.SharePath } + if ($ModulePath) { + Import-Module -Name (Join-Path -Path $ModulePath -ChildPath 'NTFSSecurity.psd1') -Force -ErrorAction Stop + } + + $administrators = 'S-1-5-32-544' + $everyone = 'S-1-1-0' + $sidType = [System.Security.Principal.SecurityIdentifier] + $synchronize = 0x100000L + } + + function Get-LabPath { + param ([string] $RelativePath) + + # Normalized, so that neither '..' nor '/' leads out of the folder of the run. + $path = [System.IO.Path]::GetFullPath((Join-Path -Path $runRoot -ChildPath $RelativePath)) + if ([System.IO.Path]::IsPathRooted($RelativePath) -or + -not $path.StartsWith($runRoot.TrimEnd('\') + '\', [System.StringComparison]::OrdinalIgnoreCase)) { + throw "'$RelativePath' must be a path in the folder of the run." + } + + $path + } + + function Get-LabOwner { + param ([string] $Path) + + (Get-LabSecurityDescriptor -Path $Path).Owner.Value + } + + function Get-LabExplicitAccessRule { + param ([string] $Path, [string] $Sid) + + (Get-Acl -LiteralPath $Path).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq $Sid } + } + + function Format-LabError { + param ([object[]] $ErrorRecord) + + foreach ($record in $ErrorRecord) { + if ($null -ne $record) { + '{0}: {1}' -f $record.FullyQualifiedErrorId, $record.Exception.Message + } + } + } + + function Format-LabRight { + param ([object] $Right) + + # .NET adds Synchronize to every allow entry that it creates. + '0x{0:X}' -f (([long]$Right) -bor $synchronize) + } +} + +AfterAll { + Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue +} + +Describe 'Account of the run' -Tag 'Delegate', 'ServerAdmin', 'Admin', 'Server' -Skip:(-not $configured) { + It 'Should run as the account of the role' { + [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value | Should -Be $configuration.Accounts.$Role.Sid + } + + It 'Should be an administrator of this computer only in the roles that are' { + $principal = New-Object -TypeName 'System.Security.Principal.WindowsPrincipal' -ArgumentList ( + [System.Security.Principal.WindowsIdentity]::GetCurrent() + ) + $expected = if ($env:COMPUTERNAME -eq $configuration.FileServer) { + $configuration.Accounts.$Role.FileServerAdministrator + } + else { + $configuration.Accounts.$Role.ClientAdministrator + } + + $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) | Should -Be $expected + } + + It 'Should test the module version of the run' -Skip:($Role -eq 'Server') { + $module = Get-Module -Name NTFSSecurity + $version = [string]$module.Version + if ($module.PrivateData.PSData.Prerelease) { + $version = '{0}-{1}' -f $version, $module.PrivateData.PSData.Prerelease + } + + $version | Should -Be $configuration.ModuleVersion + } +} + +Describe 'Access and inheritance cmdlets on a share folder whose owner the account may not assign (#34)' -Tag 'Delegate' -Skip:(-not $configured) { + # The delegated account has Full Control on the folders through a domain group, but isn't an administrator of the + # file server, so the file server refuses Administrators as the owner that the account writes: (1307) This security + # ID may not be assigned as the owner of this object. Before 5.0.0-rc3, the cmdlets wrote the unchanged owner back + # whenever they had read it: Windows returns the owner with a DACL that is read alone when the DACL has no + # auto-inherit flag, and Get-NTFSSecurityDescriptor always reads it. + Context 'With ' -ForEach $variants { + BeforeAll { + $folder = Get-LabPath -RelativePath "Case1\$Variant" + } + + It 'Should start with folders that Administrators own' { + foreach ($operation in 'AddAccess', 'RemoveAccess', 'ClearAccess', 'DisableInheritance', + 'EnableInheritance', 'SetInheritance', 'SetSecurityDescriptor') { + $path = Join-Path -Path $folder -ChildPath $operation + Get-LabOwner -Path $path | Should -Be $administrators -Because $operation + Test-LabDaclAutoInherited -Path $path | Should -Be $AutoInherited -Because $operation + } + } + + It 'Add-NTFSAccess should add the entry and keep the owner' { + $path = Join-Path -Path $folder -ChildPath 'AddAccess' + + Add-NTFSAccess -Path $path -Account $everyone -AccessRights ReadData -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-LabOwner -Path $path | Should -Be $administrators + @(Get-LabExplicitAccessRule -Path $path -Sid $everyone) | Should -HaveCount 1 + } + + It 'Remove-NTFSAccess should remove the entry and keep the owner' { + $path = Join-Path -Path $folder -ChildPath 'RemoveAccess' + @(Get-LabExplicitAccessRule -Path $path -Sid $everyone) | Should -HaveCount 1 + + Remove-NTFSAccess -Path $path -Account $everyone -AccessRights ReadAndExecute -InheritanceFlags 'ContainerInherit, ObjectInherit' -PropagationFlags None -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-LabOwner -Path $path | Should -Be $administrators + Get-LabExplicitAccessRule -Path $path -Sid $everyone | Should -BeNullOrEmpty + } + + It 'Clear-NTFSAccess should remove the explicit entries and keep the owner' { + $path = Join-Path -Path $folder -ChildPath 'ClearAccess' + + Clear-NTFSAccess -Path $path -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-LabOwner -Path $path | Should -Be $administrators + (Get-Acl -LiteralPath $path).GetAccessRules($true, $false, $sidType) | Should -BeNullOrEmpty + } + + It 'Disable-NTFSAccessInheritance should disable the inheritance and keep the owner' { + $path = Join-Path -Path $folder -ChildPath 'DisableInheritance' + + Disable-NTFSAccessInheritance -Path $path -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-LabOwner -Path $path | Should -Be $administrators + (Get-Acl -LiteralPath $path).AreAccessRulesProtected | Should -BeTrue + } + + It 'Enable-NTFSAccessInheritance should enable the inheritance and keep the owner' { + $path = Join-Path -Path $folder -ChildPath 'EnableInheritance' + (Get-Acl -LiteralPath $path).AreAccessRulesProtected | Should -BeTrue + + Enable-NTFSAccessInheritance -Path $path -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-LabOwner -Path $path | Should -Be $administrators + (Get-Acl -LiteralPath $path).AreAccessRulesProtected | Should -BeFalse + } + + It 'Set-NTFSInheritance should disable the inheritance and keep the owner' { + $path = Join-Path -Path $folder -ChildPath 'SetInheritance' + + Set-NTFSInheritance -Path $path -AccessInheritanceEnabled $false -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-LabOwner -Path $path | Should -Be $administrators + (Get-Acl -LiteralPath $path).AreAccessRulesProtected | Should -BeTrue + } + + It 'Set-NTFSSecurityDescriptor should write the added entry and keep the owner' { + $path = Join-Path -Path $folder -ChildPath 'SetSecurityDescriptor' + + $descriptor = Get-NTFSSecurityDescriptor -Path $path -ErrorVariable operationErrors -ErrorAction SilentlyContinue + Add-NTFSAccess -SecurityDescriptor $descriptor -Account $everyone -AccessRights ReadData -ErrorVariable +operationErrors -ErrorAction SilentlyContinue + Set-NTFSSecurityDescriptor -SecurityDescriptor $descriptor -ErrorVariable +operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-LabOwner -Path $path | Should -Be $administrators + @(Get-LabExplicitAccessRule -Path $path -Sid $everyone) | Should -HaveCount 1 + } + } +} + +Describe 'Audit cmdlets on a share folder' -Skip:(-not $configured) { + # Over SMB, the file server checks whether the account holds the Security privilege there; the role Server checks + # the audit entries that the runs left on the file server. + foreach ($auditCase in $auditSuccessCases) { + Context 'As ' -Tag $auditCase.AuditRole -ForEach @($auditCase) { + BeforeAll { + $folder = Get-LabPath -RelativePath "Case2\$AuditRole" + } + + It 'Get-NTFSAudit should return the audit entry of the folder' { + $entries = @(Get-NTFSAudit -Path (Join-Path -Path $folder -ChildPath 'GetAudit') -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $entries | Should -HaveCount 1 + $entries[0].Account.Sid | Should -Be $everyone + $entries[0].AuditFlags | Should -Be 'Success' + [long]$entries[0].AccessRights | Should -Be 0x10000 + } + + It 'Add-NTFSAudit should add the audit entry and keep the owner' { + $path = Join-Path -Path $folder -ChildPath 'AddAudit' + + Add-NTFSAudit -Path $path -Account $everyone -AccessRights ReadData -AuditFlags Failure -InheritanceFlags None -PropagationFlags None -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-LabOwner -Path $path | Should -Be $administrators + } + + It 'Remove-NTFSAudit should remove the audit entry and keep the owner' { + $path = Join-Path -Path $folder -ChildPath 'RemoveAudit' + + Remove-NTFSAudit -Path $path -Account $everyone -AccessRights Delete -AuditFlags Success -InheritanceFlags None -PropagationFlags None -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-LabOwner -Path $path | Should -Be $administrators + } + } + } + + Context 'As the delegated account, an administrator of the client only' -Tag 'Delegate' { + # The account has Full Control on the folders, so taking ownership succeeds, but it doesn't hold the Security + # privilege on the file server, and it may not assign Administrators as the owner again. + BeforeAll { + $folder = Get-LabPath -RelativePath 'Case2\Delegate' + } + + It 'Get-NTFSAudit should write a ReadSecurityError that names the missing privilege' { + $entries = @(Get-NTFSAudit -Path (Join-Path -Path $folder -ChildPath 'GetAudit') -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + $entries | Should -BeNullOrEmpty + @(Format-LabError -ErrorRecord $operationErrors) | Should -HaveCount 1 + $operationErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadSecurityError,*' + $operationErrors[0].Exception.Message | Should -Match 'privilege' + } + + It 'Add-NTFSAudit should write an AddAceError that names the missing privilege and leave the folder unchanged' { + $path = Join-Path -Path $folder -ChildPath 'AddAudit' + $before = (Get-LabSecurityDescriptor -Path $path).GetSddlForm('All') + + Add-NTFSAudit -Path $path -Account $everyone -AccessRights ReadData -AuditFlags Failure -InheritanceFlags None -PropagationFlags None -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + $written = (Format-LabError -ErrorRecord $operationErrors) -join ' | ' + (Get-LabSecurityDescriptor -Path $path).GetSddlForm('All') | Should -Be $before -Because "the cmdlet wrote: $written" + @(Format-LabError -ErrorRecord $operationErrors) | Should -HaveCount 1 -Because "the cmdlet wrote: $written" + $operationErrors[0].FullyQualifiedErrorId | Should -BeLike 'AddAceError,*' + $operationErrors[0].Exception.Message | Should -Match 'privilege' + } + + It 'Remove-NTFSAudit should write a RemoveAceError that names the missing privilege and leave the folder unchanged' { + $path = Join-Path -Path $folder -ChildPath 'RemoveAudit' + $before = (Get-LabSecurityDescriptor -Path $path).GetSddlForm('All') + + Remove-NTFSAudit -Path $path -Account $everyone -AccessRights Delete -AuditFlags Success -InheritanceFlags None -PropagationFlags None -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + $written = (Format-LabError -ErrorRecord $operationErrors) -join ' | ' + (Get-LabSecurityDescriptor -Path $path).GetSddlForm('All') | Should -Be $before -Because "the cmdlet wrote: $written" + @(Format-LabError -ErrorRecord $operationErrors) | Should -HaveCount 1 -Because "the cmdlet wrote: $written" + $operationErrors[0].FullyQualifiedErrorId | Should -BeLike 'RemoveAceError,*' + $operationErrors[0].Exception.Message | Should -Match 'privilege' + } + } +} + +Describe 'Get-NTFSEffectiveAccess for a domain account on a share folder' -Tag 'Admin' -Skip:(-not $configured) { + # The account gets ReadAndExecute through two nested domain groups and Write through a local group of the file + # server. Only the file server knows its local groups. The expected rights come from the S4U tokens that the file + # server and the client create for the account, which hold the same groups as the Effective Access tab there. + BeforeAll { + $path = Get-LabPath -RelativePath 'Case3\EffectiveAccess' + $subject = $configuration.Accounts.Subject.Name + } + + It 'Should return the rights through the domain groups and the local group of the file server with -ServerName, without a warning' { + $result = @(Get-NTFSEffectiveAccess -Path $path -Account $subject -ServerName $configuration.FileServerFqdn -WarningVariable operationWarnings -WarningAction SilentlyContinue -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $operationWarnings | Should -BeNullOrEmpty + $result | Should -HaveCount 1 + Format-LabRight -Right $result[0].AccessRights | Should -Be (Format-LabRight -Right $configuration.EffectiveAccess.FileServerRights) + } + + It 'Should return only the rights through the domain groups without -ServerName' { + $result = @(Get-NTFSEffectiveAccess -Path $path -Account $subject -WarningAction SilentlyContinue -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $result | Should -HaveCount 1 + Format-LabRight -Right $result[0].AccessRights | Should -Be (Format-LabRight -Right $configuration.EffectiveAccess.ClientRights) + } + + # The cmdlet page: when the remote authorization manager can't be reached, the cmdlet falls back to the local one + # and warns that the result may be inaccurate. + It 'Should fall back to the authorization manager of the client and warn when -ServerName can''t be reached' { + $result = @(Get-NTFSEffectiveAccess -Path $path -Account $subject -ServerName $configuration.UnreachableServerName -WarningVariable operationWarnings -WarningAction SilentlyContinue -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $operationWarnings.Message | Should -Contain ('The effective rights can only be computed based on group membership on this computer. ' + + 'For more accurate results, calculate effective access rights on the target computer') + $result | Should -HaveCount 1 + Format-LabRight -Right $result[0].AccessRights | Should -Be (Format-LabRight -Right $configuration.EffectiveAccess.ClientRights) + } +} + +Describe 'Get-NTFSOrphanedAccess with the entry of a deleted domain account on a share folder' -Tag 'Admin' -Skip:(-not $configured) { + BeforeAll { + $folder = Get-LabPath -RelativePath 'Case4\OrphanedAccess' + $file = Join-Path -Path $folder -ChildPath 'File.txt' + $orphan = $configuration.Accounts.Orphan.Sid + } + + It 'Should return the entry of the deleted account with its SID' { + $entries = @(Get-NTFSOrphanedAccess -Path $folder -WarningVariable operationWarnings -WarningAction SilentlyContinue -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $operationWarnings | Should -BeNullOrEmpty + $entries | Should -HaveCount 1 + $entries[0].Account.Sid | Should -Be $orphan + $entries[0].Account.AccountName | Should -BeNullOrEmpty + $entries[0].IsInherited | Should -BeFalse + } + + It 'Should return the inherited entry for a file in the folder, and nothing with -ExcludeInherited' { + $entries = @(Get-NTFSOrphanedAccess -Path $file -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + $explicitEntries = @(Get-NTFSOrphanedAccess -Path $file -ExcludeInherited -ErrorVariable +operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $entries | Should -HaveCount 1 + $entries[0].Account.Sid | Should -Be $orphan + $entries[0].IsInherited | Should -BeTrue + $explicitEntries | Should -BeNullOrEmpty + } +} + +Describe 'Paths longer than 260 characters on a share' -Tag 'Admin' -Skip:(-not $configured) { + BeforeAll { + $folder = Get-LabPath -RelativePath 'LongPath' + $file = Join-Path -Path $folder -ChildPath $configuration.LongPath + } + + It 'Get-ChildItem2 should return the file at the end of the long path' { + $files = @(Get-ChildItem2 -Path $folder -Recurse -File -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $files | Should -HaveCount 1 + $files[0].FullName.Length | Should -BeGreaterThan 260 + } + + It 'Get-NTFSAccess should return the entries of that file' { + $file.Length | Should -BeGreaterThan 260 + + $entries = @(Get-NTFSAccess -Path $file -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $entries | Should -Not -BeNullOrEmpty + } +} + +Describe 'Copy-Item2 and Move-Item2 with -WhatIf onto an existing file on a share (#108)' -Tag 'Admin' -Skip:(-not $configured) { + # Before 5.0.0-rc4, the cmdlets wrote an error with -WhatIf when the destination file existed. + BeforeAll { + $folder = Get-LabPath -RelativePath 'WhatIf' + $source = Join-Path -Path $folder -ChildPath 'Source.txt' + $destination = Join-Path -Path $folder -ChildPath 'Destination.txt' + } + + It 'Copy-Item2 should write no error and leave the destination unchanged' { + Copy-Item2 -Path $source -Destination $destination -WhatIf -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-Content -LiteralPath $destination -Raw | Should -Be 'Destination' + } + + It 'Move-Item2 should write no error and leave both files unchanged' { + Move-Item2 -Path $source -Destination $destination -WhatIf -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-Content -LiteralPath $source -Raw | Should -Be 'Source' + Get-Content -LiteralPath $destination -Raw | Should -Be 'Destination' + } +} + +Describe 'Security descriptors on the file server after the runs on the client' -Tag 'Server' -Skip:(-not $configured) { + It 'Should keep Administrators as the owner of ' -ForEach $ownedFolders { + Get-LabOwner -Path (Get-LabPath -RelativePath $Folder) | Should -Be $administrators + } + + It 'Should have explicit audit entries on ' -ForEach $auditExpectations { + $acl = Get-Acl -LiteralPath (Get-LabPath -RelativePath $Folder) -Audit + + @($acl.GetAuditRules($true, $false, $sidType)).Count | Should -Be $Count + } +} diff --git a/Tests/Lab/README.md b/Tests/Lab/README.md new file mode 100644 index 0000000..ba0880a --- /dev/null +++ b/Tests/Lab/README.md @@ -0,0 +1,124 @@ +# Live tests in a lab + +The tests in this folder run the module against a Windows file server with +domain accounts, in an [AutomatedLab](https://automatedlab.org) lab. They cover +the cases that depend on the file server or on the accounts, which the tests in +`Tests` can't cover: those run on one computer, against local folders, with +local and well-known accounts. CI doesn't run the tests in this folder, and +without a lab they skip every test. + +## Cases + +| Case | Role | What the tests check | +| --- | --- | --- | +| 1, [#34][issue-34] | Delegate | `Add-NTFSAccess`, `Remove-NTFSAccess`, `Clear-NTFSAccess`, `Disable-NTFSAccessInheritance`, `Enable-NTFSAccessInheritance`, `Set-NTFSInheritance`, and `Set-NTFSSecurityDescriptor` on share folders that Administrators own and on which a domain group has Full Control, run by a member of that group who isn't an administrator of the file server. They succeed and keep the owner. | +| 2 | Admin, ServerAdmin, Delegate | `Get-NTFSAudit`, `Add-NTFSAudit`, and `Remove-NTFSAudit` on share folders. Over SMB, the file server checks the Security privilege of the account. The administrators of the file server read and change the audit entries; the delegated account gets the errors that the cmdlet pages describe, and the folders stay unchanged. | +| 3 | Admin | `Get-NTFSEffectiveAccess` for a domain account with rights through two nested domain groups and through a local group of the file server. With `-ServerName`, the result includes the local group, without a warning; without it, the client doesn't know that group. With an unreachable server, the cmdlet falls back to the client and warns. | +| 4 | Admin | `Get-NTFSOrphanedAccess` returns the entry of a deleted domain account with its SID, on the folder and as inherited entry on a file in it. | +| Long paths | Admin | `Get-ChildItem2` and `Get-NTFSAccess` with a share path longer than 260 characters. | +| [#108][issue-108] | Admin | `Copy-Item2` and `Move-Item2` with `-WhatIf` onto an existing file on the share write no error. | +| State | Server | After the runs on the client, the file server checks the owners and the audit entries of the folders itself, without the module. | + +Case 1 uses two kinds of folders. Before 5.0.0-rc3, the cmdlets wrote back the +owner that Windows returns with a DACL without the auto-inherit flag, and the +file server refused it with error 1307, "This security ID may not be assigned +as the owner of this object". Windows sets that flag whenever it writes a DACL +with `SetNamedSecurityInfo`, so the fixture stores the DACL of one kind of +folders again with `SetFileSecurity`, without the flag, like tools that predate +Windows 2000. `Set-NTFSSecurityDescriptor` wrote the owner on both kinds. + +The expected rights of case 3 come from the tokens that the file server and the +client create for the account with a Kerberos S4U logon, the way the Effective +Access tab of the advanced security settings does. + +## Roles + +| Role | Account | Administrator of the client | Administrator of the file server | +| --- | --- | --- | --- | +| Delegate | `NtfsLiveDelegate`, member of `NtfsLiveDelegates` | Yes | No | +| ServerAdmin | `NtfsLiveServerAdmin`, member of Remote Management Users on the client | No | Yes | +| Admin | `NtfsLiveAdmin` | Yes | Yes | +| Server | The installation account of the lab, on the file server | Yes | Yes | + +The script also creates `NtfsLiveSubject`, the account of case 3, which is a +member of `NtfsLiveInner`, a member of `NtfsLiveOuter`, and of the local group +`NtfsLiveLocal` of the file server, and `NtfsLiveOrphan`, which it deletes in +every run. + +## Lab + +The lab needs a domain controller, a file server, and a client of one domain, +PowerShell 7 and Pester 5.7.1 on the client and the file server, and +remoting with CredSSP from the host, which AutomatedLab sets up. The defaults +use the lab of +[WindowsAccessControl](https://github.com/raandree/WindowsAccessControl), which +`tests/Lab/Deploy-WindowsAccessControlLab.ps1` in that repository deploys: +`F1ADC1` as domain controller, `F1AFile2` as file server, and `F1AFile1` as +client, all in `a.forest1.net`. `-DomainController`, `-FileServer`, and +`-Client` select other machines. + +The script adds to the lab: + +- the organizational unit `NTFSSecurityLive` with the accounts and groups +- the local group `NtfsLiveLocal` and members of Administrators on the file + server, and members of Administrators and Remote Management Users on the + client +- the share `NTFSSecurityLive` on `C:\NTFSSecurityLive` of the file server, + with a folder for each run +- the folder `C:\NTFSSecurityLab` with the tests on the file server, and with + the modules and the tests on the client + +## Run the tests + +In an elevated Windows PowerShell 5.1 session on the Hyper-V host of the lab: + +```powershell +.\Tests\Lab\Invoke-NTFSSecurityLabTest.ps1 -Version 5.0.0-rc2, 5.0.0-rc4 -Confirm:$false +``` + +The script downloads each version from the PowerShell Gallery, checks the hash +that the gallery publishes, and runs the tests for each version in Windows +PowerShell 5.1 and PowerShell 7. Each version runs in its own process, because +all versions of `NTFSSecurity.dll` have the same assembly version. To test a +build, add `-ModulePath .\NTFSSecurity\bin\Release`; it runs as the version +`local`. + +The script sets new random passwords for the accounts in every call and keeps +them in memory only. The tests refuse to run on a computer other than the +client and the file server of the configuration, and on a folder outside the +share. + +Remove everything the script added to the lab: + +```powershell +.\Tests\Lab\Invoke-NTFSSecurityLabTest.ps1 -RemoveFixture +``` + +## Results + +Each call writes to a new folder in `$env:TEMP\NTFSSecurityLab\Results`: + +- `Summary.md` and `Summary.json`: the counts per version, edition, and role, + and the failed tests with their messages +- `-.result.json` and `-.log`: the result and the + error message of each test that ran, and the output of Pester +- `.json`: the configuration of the run +- `-State.json`: the stored owner, group, and DACL, and the SACL of each + folder after the run + +A version before 5.0.0-rc3 fails case 1 with error 1307, a version before +5.0.0-rc4 fails the tests of #108, and a version before 5.0.0-rc5 fails the +test of case 3 with a computer that can't be reached: it returned no access +instead of the result of the client. + +## Files + +| File | Purpose | +| --- | --- | +| `Invoke-NTFSSecurityLabTest.ps1` | Prepares the lab, runs the tests, and writes the results; runs on the host. | +| `NTFSSecurity.Live.Tests.ps1` | The tests; run on the client and the file server. | +| `Start-NTFSSecurityLiveTest.ps1` | Runs the tests of one role in a new process. | +| `NTFSSecurity.LabHelpers.ps1` | Reads and writes security descriptors as Windows stores them, and calculates the expected rights. | + +[issue-34]: https://github.com/raandree/NTFSSecurity/issues/34 +[issue-108]: https://github.com/raandree/NTFSSecurity/issues/108 diff --git a/Tests/Lab/Start-NTFSSecurityLiveTest.ps1 b/Tests/Lab/Start-NTFSSecurityLiveTest.ps1 new file mode 100644 index 0000000..bf414b1 --- /dev/null +++ b/Tests/Lab/Start-NTFSSecurityLiveTest.ps1 @@ -0,0 +1,85 @@ +<# +.SYNOPSIS + Runs NTFSSecurity.Live.Tests.ps1 for one role and writes the result file. + +.DESCRIPTION + Invoke-NTFSSecurityLabTest.ps1 starts this script in a new Windows PowerShell 5.1 or PowerShell 7 process on the + client or the file server of the lab, as the account of the role. Each module version runs in its own process, + because all versions of NTFSSecurity.dll have the same assembly version, and a second import in a process would + use the first DLL. Exits with 0 when all tests passed and with 1 otherwise. + +.PARAMETER ModulePath + The folder that contains NTFSSecurity.psd1 of the version to test. The role Server needs no module. + +.PARAMETER ConfigurationPath + The configuration of the run that Invoke-NTFSSecurityLabTest.ps1 wrote. + +.PARAMETER Role + The role whose tests run: Delegate, ServerAdmin, Admin, or Server. + +.PARAMETER ResultPath + The path of the result file: a JSON array with the name, the result, and the error message of each test that + ran, and of each test file that failed. Pester's result formats read the operating system through CIM, which an + account that isn't an administrator can't use in a remote session. + +.EXAMPLE + .\Start-NTFSSecurityLiveTest.ps1 -ModulePath C:\NTFSSecurityLab\Modules\5.0.0-rc4\NTFSSecurity -ConfigurationPath C:\NTFSSecurityLab\Configuration\Run.json -Role Delegate -ResultPath $env:TEMP\Delegate.json + + Runs the tests of the delegated account against 5.0.0-rc4. +#> +[CmdletBinding()] +param ( + [string] + $ModulePath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $ConfigurationPath, + + [Parameter(Mandatory)] + [ValidateSet('Delegate', 'ServerAdmin', 'Admin', 'Server')] + [string] + $Role, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $ResultPath +) + +$ErrorActionPreference = 'Stop' +Import-Module -Name Pester -RequiredVersion 5.7.1 + +$data = @{ + ModulePath = $ModulePath + ConfigurationPath = $ConfigurationPath + Role = $Role +} +$configuration = New-PesterConfiguration +$configuration.Run.Container = New-PesterContainer -Path (Join-Path -Path $PSScriptRoot -ChildPath 'NTFSSecurity.Live.Tests.ps1') -Data $data +$configuration.Run.PassThru = $true +$configuration.Filter.Tag = $Role +$configuration.Output.Verbosity = 'Detailed' +$configuration.Output.RenderMode = 'Plaintext' +$result = Invoke-Pester -Configuration $configuration + +$tests = foreach ($test in $result.Tests | Where-Object -FilterScript { $_.Result -ne 'NotRun' }) { + [pscustomobject]@{ + Name = $test.ExpandedPath + Result = [string]$test.Result + Message = (@($test.ErrorRecord) | Where-Object -FilterScript { $_ } | ForEach-Object -Process { $_.ToString() }) -join [Environment]::NewLine + } +} + +# A test file fails also when only its tests fail; it is listed only when it failed with an error of its own. +$failedFiles = foreach ($container in $result.Containers | Where-Object -FilterScript { $_.Result -eq 'Failed' -and @($_.ErrorRecord).Count -gt 0 }) { + [pscustomobject]@{ + Name = 'Test file {0}' -f $container.Item + Result = 'Failed' + Message = (@($container.ErrorRecord) | Where-Object -FilterScript { $_ } | ForEach-Object -Process { $_.ToString() }) -join [Environment]::NewLine + } +} + +ConvertTo-Json -InputObject @(@($tests) + @($failedFiles)) -Depth 3 | Set-Content -LiteralPath $ResultPath -Encoding UTF8 +exit [int]($result.Result -ne 'Passed') From 53e5bc06cfbd67697187cd9c9c35f7726b91b886 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Wed, 7 Oct 2026 16:21:22 +0000 Subject: [PATCH 2/4] fix: fall back to this computer when -ServerName is unreachable Get-NTFSEffectiveAccess -ServerName with a computer that can't be resolved or reached returned no access, while it warned that it had calculated the result on this computer. In that case, AuthzInitializeRemoteResourceManager fails with RPC_S_SERVER_UNAVAILABLE (1722); the code fell back to the local authorization manager only for EPT_S_NOT_REGISTERED (1753), and GetEffectiveAccess swallowed the exception. It now falls back for 1722 as well, as the cmdlet page describes. The live tests in a lab found it; the new test in Access.Tests.ps1 reproduces it on any computer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- CHANGELOG.md | 3 +++ Docs/Cmdlets/Get-NTFSEffectiveAccess.md | 2 +- NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml | 2 +- Security2/Win32/Lib.cs | 4 +++- Tests/Access.Tests.ps1 | 18 ++++++++++++++++++ 5 files changed, 26 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41e112b..1a80a74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -266,5 +266,8 @@ The format is based on of `Get-FileHash2`, which named the AlphaFS `FileInfo` instead of the type name of its objects ([#111](https://github.com/raandree/NTFSSecurity/issues/111)) +- Fix `Get-NTFSEffectiveAccess`, which returned no access when the computer + of `-ServerName` couldn't be reached, although it warned that it had + calculated the result on this computer; it now returns that result [Unreleased]: https://github.com/raandree/NTFSSecurity/compare/4.2.6...HEAD diff --git a/Docs/Cmdlets/Get-NTFSEffectiveAccess.md b/Docs/Cmdlets/Get-NTFSEffectiveAccess.md index 9a24f8a..63ce34f 100644 --- a/Docs/Cmdlets/Get-NTFSEffectiveAccess.md +++ b/Docs/Cmdlets/Get-NTFSEffectiveAccess.md @@ -182,7 +182,7 @@ When the module setting `EnablePrivileges` is `$true` (the default in the `Priva Reading effective access needs the Security privilege. In a session that does not hold it, the cmdlet warns before it starts and the calculation may fail with an error. Use `Enable-Privileges` in an elevated session to enable the privilege, and `Get-Privileges` to see which privileges the session holds. When the calculation fails, the error names the cause that Windows reported, such as a security descriptor without an owner; before 5.0.0, it blamed a missing Security privilege whenever the privilege wasn't enabled. -Before 5.0.0, `-ExcludeNoneAccessEntries` had no effect, and the cmdlet returned nothing without `-Path` or for `-SecurityDescriptor`. +Before 5.0.0, `-ExcludeNoneAccessEntries` had no effect, and the cmdlet returned nothing without `-Path` or for `-SecurityDescriptor`. When the computer of `-ServerName` couldn't be reached, the cmdlet warned that it had calculated the result on this computer, but returned no access instead of that result. ## RELATED LINKS diff --git a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml index 587951c..0f6bb54 100644 --- a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml +++ b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml @@ -5152,7 +5152,7 @@ PS C:\> Get-NTFSAudit -SecurityDescriptor $sd When the module setting `EnablePrivileges` is `$true` (the default in the `PrivateData` section of NTFSSecurity.psd1), this cmdlet tries to enable the Backup, Restore, Take Ownership, and Security privileges while it runs and disables the privileges it enabled when it finishes. These privileges are only available in an elevated session of an account that holds them, such as a member of the local Administrators group. If a privilege cannot be enabled, the cmdlet continues without it and writes a debug message. Reading effective access needs the Security privilege. In a session that does not hold it, the cmdlet warns before it starts and the calculation may fail with an error. Use `Enable-Privileges` in an elevated session to enable the privilege, and `Get-Privileges` to see which privileges the session holds. When the calculation fails, the error names the cause that Windows reported, such as a security descriptor without an owner; before 5.0.0, it blamed a missing Security privilege whenever the privilege wasn't enabled. - Before 5.0.0, `-ExcludeNoneAccessEntries` had no effect, and the cmdlet returned nothing without `-Path` or for `-SecurityDescriptor`. + Before 5.0.0, `-ExcludeNoneAccessEntries` had no effect, and the cmdlet returned nothing without `-Path` or for `-SecurityDescriptor`. When the computer of `-ServerName` couldn't be reached, the cmdlet warned that it had calculated the result on this computer, but returned no access instead of that result. diff --git a/Security2/Win32/Lib.cs b/Security2/Win32/Lib.cs index 55be9a0..9df1595 100644 --- a/Security2/Win32/Lib.cs +++ b/Security2/Win32/Lib.cs @@ -173,7 +173,9 @@ namespace Security2 { int error = Marshal.GetLastWin32Error(); - if (error != Win32Error.EPT_S_NOT_REGISTERED) //if not RPC server unavailable + // The computer can't be resolved or reached (RPC server unavailable), or it doesn't offer the remote + // interface (endpoint not registered); the local authorization manager calculates the result instead. + if (error != Win32Error.EPT_S_NOT_REGISTERED && error != Win32Error.RPC_S_SERVER_UNAVAILABLE) { throw new Win32Exception(error); } diff --git a/Tests/Access.Tests.ps1 b/Tests/Access.Tests.ps1 index faa4538..fee022f 100644 --- a/Tests/Access.Tests.ps1 +++ b/Tests/Access.Tests.ps1 @@ -131,6 +131,24 @@ Describe 'Get-NTFSEffectiveAccess' { $accessErrors[0].Exception.Message | Should -Not -BeLike '*Enable-Privileges*' } } + + Context 'When the computer of -ServerName cannot be reached' { + # Before 5.0.0-rc5, the cmdlet returned no access when the computer couldn't be reached, although it warned that + # it had calculated the result on this computer. Windows reports a computer that it can't resolve or reach with + # the error RPC server unavailable; the name ends in .invalid, which no DNS server resolves (RFC 2606). + It 'Should return the result of this computer and warn' { + $expected = Get-NTFSEffectiveAccess -Path $effectiveFile -WarningAction SilentlyContinue -ErrorAction Stop + [long] $expected.AccessRights | Should -BeGreaterThan ([long] [Security2.FileSystemRights2]::Synchronize) + + $result = @(Get-NTFSEffectiveAccess -Path $effectiveFile -ServerName 'ntfssecurity-test.invalid' -WarningVariable accessWarnings -WarningAction SilentlyContinue -ErrorVariable accessErrors -ErrorAction SilentlyContinue) + + $accessErrors | Should -BeNullOrEmpty + $result | Should -HaveCount 1 + $result[0].AccessRights | Should -Be $expected.AccessRights + $accessWarnings.Message | Should -Contain ('The effective rights can only be computed based on group membership on this computer. ' + + 'For more accurate results, calculate effective access rights on the target computer') + } + } } Describe 'Get-NTFSOrphanedAccess' { From 2957ba271f2162365f58129dc2d69656a0781343 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Wed, 7 Oct 2026 16:21:22 +0000 Subject: [PATCH 3/4] chore(release): prepare 5.0.0-rc5 Set the prerelease label rc5, and add 5.0.0-rc4 to the versions that the PowerShell Gallery already has. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- NTFSSecurity/NTFSSecurity.psd1 | 2 +- Tests/Repository.Tests.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/NTFSSecurity/NTFSSecurity.psd1 b/NTFSSecurity/NTFSSecurity.psd1 index f67658f..74894e3 100644 --- a/NTFSSecurity/NTFSSecurity.psd1 +++ b/NTFSSecurity/NTFSSecurity.psd1 @@ -102,7 +102,7 @@ ProjectUri = 'https://github.com/raandree/NTFSSecurity' ReleaseNotes = 'https://github.com/raandree/NTFSSecurity/blob/master/CHANGELOG.md' # Remove the prerelease label for the final release, see Docs/Contributing/05-Releasing.md - Prerelease = 'rc4' + Prerelease = 'rc5' } } } \ No newline at end of file diff --git a/Tests/Repository.Tests.ps1 b/Tests/Repository.Tests.ps1 index 3418f37..4380bdd 100644 --- a/Tests/Repository.Tests.ps1 +++ b/Tests/Repository.Tests.ps1 @@ -97,7 +97,7 @@ Describe 'Release metadata' { # The PowerShell Gallery doesn't accept a version twice. Add every published version to this list # (Docs/Contributing/05-Releasing.md). It 'Should not reuse a version that the PowerShell Gallery already has' { - $publishedVersions = '4.0', '4.2.2', '4.2.3', '4.2.4', '4.2.5', '4.2.6', '5.0.0-rc1', '5.0.0-rc2', '5.0.0-rc3' + $publishedVersions = '4.0', '4.2.2', '4.2.3', '4.2.4', '4.2.5', '4.2.6', '5.0.0-rc1', '5.0.0-rc2', '5.0.0-rc3', '5.0.0-rc4' $publishedVersions | Should -Not -Contain $version } From e2dd2fa92507268999d2b46d63b89fc83828b456 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Wed, 7 Oct 2026 16:21:22 +0000 Subject: [PATCH 4/4] chore(memory-bank): record the live tests and plan 5.0.0-rc5 Record the results of the live tests against 5.0.0-rc2, 5.0.0-rc4, and the rc5 build, Decision 20 for the location of the live tests, the third workstation, the deferred review findings, and the next steps to 5.0.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- .memory-bank/activeContext.md | 81 ++++++++++--------- .../decisions/0020-live-tests-in-tests-lab.md | 27 +++++++ .memory-bank/progress.md | 32 ++++++-- .memory-bank/systemPatterns.md | 10 ++- .memory-bank/techContext.md | 27 ++++++- 5 files changed, 127 insertions(+), 50 deletions(-) create mode 100644 .memory-bank/decisions/0020-live-tests-in-tests-lab.md diff --git a/.memory-bank/activeContext.md b/.memory-bank/activeContext.md index 8ddb0ee..935054f 100644 --- a/.memory-bank/activeContext.md +++ b/.memory-bank/activeContext.md @@ -1,6 +1,6 @@ --- status: current -last-verified: 2026-10-06 +last-verified: 2026-10-07 owner: active-agent source: current task evidence --- @@ -9,47 +9,50 @@ source: current task evidence ## Current focus -Live tests of 5.0.0-rc4 in a lab, which the maintainer decided on -2026-10-06 to run before 5.0.0. He continues on another workstation that -has his lab script. 5.0.0 waits for these results and for the tester -feedback in #34; then the repository is archived in favor of -WindowsAccessControl (Decision 18). +5.0.0-rc5, prepared on the branch `ai/release-5.0.0-rc5`: the live tests +in `Tests\Lab` (Decision 20) and the fix of +`Get-NTFSEffectiveAccess -ServerName` that they found. The maintainer +pushes the branch, opens and merges the pull request, and tags +`5.0.0-rc5`; CI publishes it (Decision 12). Then, with the tester feedback +in #34, he decides on 5.0.0. After 5.0.0 the repository is archived in +favor of WindowsAccessControl (Decision 18). ## Evidence -- 2026-10-06: 5.0.0-rc4 is published from the tag `5.0.0-rc4` on - `01d9264`, the merge commit of #113, and verified from the Gallery - package in both editions; its issues are closed (progress). -- The Pester tests run only on standalone machines against local NTFS - folders, with local and well-known accounts. No test uses a UNC path, a - share, a domain account, or `Get-NTFSEffectiveAccess -ServerName`, which - calls `AuthzInitializeRemoteResourceManager` over RPC. 5.0.0 changed no - code that is specific to domains or SMB. -- In #34, the tester wrote on 2026-10-06 that the owner problem also exists - on their IBM ESS system, and that he reports rc3 results against both - their file servers on 2026-10-07. No lab reproduces IBM ESS. -- The second workstation has Hyper-V and AutomatedLab 5.61, but no - LabSources folder, ISO, or lab. -- Hand commands to the maintainer as fenced code blocks at the end of the - reply, and end the turn there; never put them in the question dialog, - which also covers a reply before it. A pull request names an issue - without a closing keyword unless the merge should close it (techContext). +- 2026-10-07: the live tests ran in the lab of WindowsAccessControl + (`F1ADC1`, `F1AFile2`, `F1AFile1` in `a.forest1.net`) against the Gallery + packages of 5.0.0-rc2 and 5.0.0-rc4 and against the branch build, in + Windows PowerShell 5.1 and PowerShell 7, with the same results in both: + - Case 1 (#34 over SMB): rc2 fails `Add-NTFSAccess`, `Clear-NTFSAccess`, + and `Set-NTFSSecurityDescriptor` with error 1307, on folders with and + without the auto-inherit flag; the other four cmdlets succeed in rc2 + too. rc4 passes all seven and keeps Administrators as the owner. + - Case 2: the administrators of the file server read, add, and remove + audit entries over SMB; the delegated account gets the errors that the + cmdlet pages describe, and the folders stay unchanged. Same in rc2. + - Case 3: with `-ServerName` of the file server, the result includes its + local group, without a warning; without it, only the domain groups + count. With a computer that can't be reached, rc2 and rc4 returned no + access, because error 1722 was swallowed; fixed on the branch. + - Case 4, long paths on the share, and #108 pass; #108 fails in rc2. + - The branch build passes every live test, and the 499 tests of `Tests` + in both editions. +- One `security-reviewer` pass approved the branch with minor findings; + the assertion of the fallback warning and the path guard of the live + tests were hardened. Deferred: the bare `catch` in + `Win32.GetEffectiveAccess`, which still swallows any other error of the + remote initialization (not reproducible: for a user who isn't an + administrator of the file server, the cmdlet writes "Access is denied"); + the unchecked `AUTHZ_ACCESS_REPLY.Error`; a fallback warning that names + the server and the error, which would change behavior (Decision 16). +- #34: no report from the tester by 16:00 UTC on 2026-10-07; he announced + results against two file servers, one of them IBM ESS, for that day. +- The lab keeps the accounts, the share, and the folders of the last run; + `Invoke-NTFSSecurityLabTest.ps1 -RemoveFixture` removes them. ## Next step -Build the lab with the maintainer's script: a domain controller, a file -server with a share, and a client. From the client, run live tests against -5.0.0-rc2 as the baseline and 5.0.0-rc4, in Windows PowerShell 5.1 and -PowerShell 7: - -1. #34 over SMB: a domain user who isn't an admin on the file server, with - Full Control on a share folder owned by Administrators, runs the access - and inheritance cmdlets on its UNC path. rc2 should fail with (1307); - rc4 should succeed and keep the owner. -2. The audit cmdlets over SMB, where the file server, not the client, - checks the Security privilege: as a user with and without it there. -3. `Get-NTFSEffectiveAccess` for domain accounts with nested groups, and - with `-ServerName`. -4. `Get-NTFSOrphanedAccess` with the entry of a deleted domain account. - -Then, with the #34 feedback, release 5.0.0 as `progress.md` describes. +The maintainer runs the push and pull request commands of the session of +2026-10-07, merges, and tags `5.0.0-rc5`. Then check the published package +with `Invoke-NTFSSecurityLabTest.ps1 -Version 5.0.0-rc5`, wait for the #34 +feedback, and release 5.0.0 as `progress.md` describes. diff --git a/.memory-bank/decisions/0020-live-tests-in-tests-lab.md b/.memory-bank/decisions/0020-live-tests-in-tests-lab.md new file mode 100644 index 0000000..58e2aaa --- /dev/null +++ b/.memory-bank/decisions/0020-live-tests-in-tests-lab.md @@ -0,0 +1,27 @@ +--- +status: accepted +date: 2026-10-07 +last-verified: 2026-10-07 +owner: shared +source: maintainer decision of 2026-10-07 +--- + +# Decision 20: Live tests in a lab live in Tests\Lab + +- Choice: The live tests that need a file server and domain accounts live + in `Tests\Lab`: the Pester file `NTFSSecurity.Live.Tests.ps1`, the + controller `Invoke-NTFSSecurityLabTest.ps1`, which prepares an + AutomatedLab lab and runs the tests per module version and PowerShell + edition, and a README with the cases. CI excludes the folder through + `Run.ExcludePath` in `.github/scripts/Invoke-Tests.ps1`, and without a + configuration every live test skips. The default lab is the one of + WindowsAccessControl (`F1ADC1`, `F1AFile2`, `F1AFile1` in + `a.forest1.net`), which the maintainer allowed on 2026-10-07. +- Rationale: The tests in `Tests` run on one computer with local accounts. + #34 over SMB, the Security privilege that the file server checks, + `Get-NTFSEffectiveAccess -ServerName`, and orphaned domain accounts need a + file server and a domain. In the repository, a later release can repeat + the tests and review them in a pull request. +- Rejected: a handoff folder outside the repository, which keeps the tests + on one machine. +- Applied: 5.0.0-rc5. diff --git a/.memory-bank/progress.md b/.memory-bank/progress.md index cc266fb..e17a9d8 100644 --- a/.memory-bank/progress.md +++ b/.memory-bank/progress.md @@ -1,6 +1,6 @@ --- status: current -last-verified: 2026-10-06 +last-verified: 2026-10-07 owner: active-agent source: repository evidence --- @@ -13,8 +13,11 @@ source: repository evidence published by CI from the tag `5.0.0-rc4` on `master` (`01d9264`, the merge of #113) on 2026-10-06 (Decision 12). It adds to 5.0.0-rc3 the fixes of the issues #41, #108, #109, and #111 and of the leftovers of the rc3 review. -The stable Gallery version is still 4.2.6. NTFSSecurity will be archived -soon; its users move to WindowsAccessControl (Decision 18). +5.0.0-rc5 is prepared on the branch `ai/release-5.0.0-rc5`: the live tests +in a lab (Decision 20) and the fix of `Get-NTFSEffectiveAccess -ServerName` +that they found. The stable Gallery version is still 4.2.6. NTFSSecurity +will be archived soon; its users move to WindowsAccessControl +(Decision 18). ## Recent milestones @@ -56,6 +59,15 @@ soon; its users move to WindowsAccessControl (Decision 18). small items (#111). One `security-reviewer` pass approved it; its Minor findings R1, R2, R6, and R8 were fixed before the merge. #41, #108, #109, and #111 closed as completed, #90 and #107 as not planned. +- 2026-10-07: live tests in the lab of WindowsAccessControl (Decision 20) + against 5.0.0-rc2 and 5.0.0-rc4 in both editions: rc2 fails #34 over SMB + with error 1307 for `Add-NTFSAccess`, `Clear-NTFSAccess`, and + `Set-NTFSSecurityDescriptor`; rc4 passes the four cases, except + `Get-NTFSEffectiveAccess -ServerName` with a computer that can't be + reached, which returned no access since before rc1. The maintainer chose + to fix it test-first in 5.0.0-rc5; the branch build passes all live tests + and the suite. One `security-reviewer` pass approved it with minor + findings (`activeContext.md`). ## Stable capabilities @@ -70,9 +82,10 @@ soon; its users move to WindowsAccessControl (Decision 18). ## Open work -1. Release 5.0.0 through CI (Decision 12) after the live tests in a lab - (`activeContext.md`) and the tester feedback in #34: remove the label, - date `[Unreleased]` as `[5.0.0]`, add `5.0.0-rc4` to +1. Publish 5.0.0-rc5 (merge, then tag), check its package in the lab with + `Tests\Lab\Invoke-NTFSSecurityLabTest.ps1 -Version 5.0.0-rc5`, and then + release 5.0.0 through CI (Decision 12) with the tester feedback in #34: + remove the label, date `[Unreleased]` as `[5.0.0]`, add `5.0.0-rc5` to `$publishedVersions`, and tag `5.0.0` (steps in `Docs/Contributing/05-Releasing.md`). #34 stays open with Bug and Help Wanted until a tester with a file server that refuses the owner @@ -86,7 +99,12 @@ soon; its users move to WindowsAccessControl (Decision 18). snapshots on read paths, and a duplicate SACL check; of rc4, R3 to R5, R7, and five older defects, listed in the description of #113, among them the accounts filter that `RemoveFileSystemAccessRuleAll` and - `RemoveFileSystemAuditRuleAll` ignore, which no cmdlet passes. + `RemoveFileSystemAuditRuleAll` ignore, which no cmdlet passes; of rc5, + the bare `catch` in `Win32.GetEffectiveAccess`, the unchecked + `AUTHZ_ACCESS_REPLY.Error`, a fallback warning without the server name, + and hardening of the lab controller (guards in the setup blocks, + interpolated `-EncodedCommand` paths, CredSSP by IP address, the + password string in memory, disabling the role accounts after a run). 4. `pwsh` 7.6.1 crashed three times during test runs on the ARM64 workstation (x64 emulation), without module frames; none of the CI runs on native x64 on 2026-10-05 crashed. diff --git a/.memory-bank/systemPatterns.md b/.memory-bank/systemPatterns.md index c2dbc99..c28e09b 100644 --- a/.memory-bank/systemPatterns.md +++ b/.memory-bank/systemPatterns.md @@ -1,6 +1,6 @@ --- status: current -last-verified: 2026-10-06 +last-verified: 2026-10-07 owner: active-agent source: repository evidence --- @@ -66,6 +66,7 @@ Each Decision record is a file in `decisions/`; read only the relevant ones. | 17 | [Issue labels](decisions/0017-issue-labels.md) | | 18 | [NTFSSecurity will be archived](decisions/0018-archive-for-windowsaccesscontrol.md) | | 19 | [Cmdlets write only the sections that they change](decisions/0019-write-only-changed-sections.md) | +| 20 | [Live tests in a lab live in Tests\Lab](decisions/0020-live-tests-in-tests-lab.md) | ## Patterns @@ -96,3 +97,10 @@ Each Decision record is a file in `decisions/`; read only the relevant ones. hook `BypassOnlineHelpRetrieval`. `Manifest.Tests.ps1` and `Release.Tests.ps1` check the manifest, the version (Decision 10), the release notes, and the packages. +- The live tests in `Tests\Lab` (Decision 20) run as domain accounts in a + lab: on the client over SMB, then on the file server, which checks what + the client runs left. They read and write descriptors as Windows stores + them with `GetFileSecurity` and `SetFileSecurity`, because + `GetNamedSecurityInfo` converts a DACL without the auto-inherit flag and + returns its owner. The expected effective rights come from the S4U tokens + of the file server and the client, like the Effective Access tab. diff --git a/.memory-bank/techContext.md b/.memory-bank/techContext.md index 9b4a715..625f722 100644 --- a/.memory-bank/techContext.md +++ b/.memory-bank/techContext.md @@ -1,6 +1,6 @@ --- status: current -last-verified: 2026-10-04 +last-verified: 2026-10-07 owner: active-agent source: repository evidence --- @@ -69,10 +69,23 @@ source: repository evidence same way, its folder first on `$env:PSModulePath` of the test process. The GitHub CLI is in `C:\Program Files\GitHub CLI`, outside the PATH of sessions started before its installation. +- The third workstation (`ExHost`, a Windows Server 2025 VM, x64, used since + 2026-10-07) runs the agent session elevated and hosts the AutomatedLab lab + `WindowsAccessControlLab` (Decision 20) with Hyper-V and AutomatedLab + 5.61.704. It has no NuGet cache, platyPS, or GitHub CLI: check each + nuget.org package against the SHA-512 `packageHash` of its catalog entry + (`https://api.nuget.org/v3/registration5-semver1//.json`, + then `catalogEntry`), and each Gallery package against `PackageHash` of + `api/v2/Packages(Id='',Version='')`. Pester 5.7.1 is in + `V:\Git\WindowsAccessControl\output\RequiredModules`; read issues and pull + requests through the GitHub REST API. The lab domains `a.forest1.net` and + `b.forest1.net` had a maximum password age of 42 days, so the password of + `install` expired on 2026-09-15 and AutomatedLab got access denied; it + never expires since 2026-10-07, as in `forest1.net`. ## Constraints -- `ModuleVersion` on `master` is `5.0.0` with the prerelease label `rc4`. +- `ModuleVersion` is `5.0.0` with the prerelease label `rc5`. The latest stable tag and Gallery release is `4.2.6`. The manifest requires PowerShell 5.1 and .NET Framework 4.5.2, uses `RootModule`, and lists exactly 36 cmdlets; `Test-ModuleManifest` passes in Windows @@ -108,7 +121,9 @@ source: repository evidence at the end of the reply, which the chat shows with a copy button, and end the turn there; the maintainer reports back in the chat. The question dialog joins the lines of its text, has no copy button, and covers the - reply before it (maintainer, 2026-10-06). A pull request description + reply before it (maintainer, 2026-10-06). A long question also hides its + choices, so that it can't be answered: keep it to a few short sentences + (2026-10-07). A pull request description names an issue without a closing keyword (fixes, closes, resolves) unless the merge should close it: "fixes #34" in #112 closed #34. Simulated `gh` commands in offline tests must print what the real ones print, such as @@ -160,6 +175,12 @@ source: repository evidence session. Run them as a basic user with `runas /trustlevel:0x20000`, and give Windows PowerShell its own `PSModulePath`; that token holds one privilege, so the `Enable-Privileges -PassThru` count test fails there. +- Live tests (Decision 20): in an elevated Windows PowerShell 5.1 session + on the lab host, `Tests\Lab\Invoke-NTFSSecurityLabTest.ps1` with + `-Version` for Gallery packages or `-ModulePath` for a build; it writes + the results to `$env:TEMP\NTFSSecurityLab\Results`. A run of two versions + in both editions takes about 30 minutes; `-RemoveFixture` removes its + accounts, share, and folders from the lab. - Markdown lint: `npx markdownlint-cli2` with `MD013` limited to prose (tables, code, and headings excluded) on the conceptual pages; for `CHANGELOG.md` also `MD024` with `siblings_only: true`, because every