You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

706 lines
36 KiB

<#
Tests the access cmdlets of the module built in NTFSSecurity\bin\Release on files in a sandbox folder.
#>
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.'
)]
param ()
BeforeDiscovery {
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force
# With the Restore privilege, Windows may grant writing the DACL despite a deny entry.
$canBypassWriteDeny = Test-PrivilegeHeld -Name 'SeRestorePrivilege'
fix: write only the sections of the security descriptor that a cmdlet changes The access and audit cmdlets wrote the owner of an item back with the entries they changed. For a DACL without the auto-inherit flag, Windows returns the owner and the group even when only the DACL is read, and the cmdlets wrote every section that the descriptor held. Without the Restore privilege, or on a file server that refuses the owner, the write failed with error 1307 (#34). - Add-NTFSAccess, Clear-NTFSAccess, Add-NTFSAudit, and Clear-NTFSAudit read only the DACL or the SACL. FileSystemSecurity2.Write(), Remove-NTFSAccess, and Remove-NTFSAudit write only the sections they read, which also fixes the access inheritance cmdlets. - Read together with the SACL, the inherited entries of such a DACL lose their inherited flag when the parent folder has no SACL, and the cmdlets stored them as explicit copies. Get-NTFSSecurityDescriptor now reads the DACL in a separate call. - Set-NTFSSecurityDescriptor writes only the sections that changed since they were read; an unchanged descriptor writes nothing (maintainer decision of 2026-10-06). - Clear-NTFSAudit writes nothing for an item without a SACL, and reports an error without the Security privilege (maintainer decision). The regression tests failed before and pass after the fix in Windows PowerShell 5.1 and PowerShell 7, elevated and as a basic user. Co-authored-by: Copilot &lt;223556219+Copilot@users.noreply.github.com&gt; Co-authored-by: AI Assistant &lt;ai@example.com&gt;
4 weeks ago
# Assigning an owner other than the user or one of its groups needs the Restore privilege.
$canAssignAnyOwner = Test-PrivilegeHeld -Name 'SeRestorePrivilege'
$holdsSecurityPrivilege = Test-PrivilegeHeld -Name 'SeSecurityPrivilege'
}
BeforeAll {
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force
$modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\NTFSSecurity\bin\Release\NTFSSecurity.psd1'
Import-Module -Name $modulePath -Force -ErrorAction Stop
$sandbox = New-TestSandbox -Name 'Access'
Push-Location -LiteralPath $sandbox
fix: write only the sections of the security descriptor that a cmdlet changes The access and audit cmdlets wrote the owner of an item back with the entries they changed. For a DACL without the auto-inherit flag, Windows returns the owner and the group even when only the DACL is read, and the cmdlets wrote every section that the descriptor held. Without the Restore privilege, or on a file server that refuses the owner, the write failed with error 1307 (#34). - Add-NTFSAccess, Clear-NTFSAccess, Add-NTFSAudit, and Clear-NTFSAudit read only the DACL or the SACL. FileSystemSecurity2.Write(), Remove-NTFSAccess, and Remove-NTFSAudit write only the sections they read, which also fixes the access inheritance cmdlets. - Read together with the SACL, the inherited entries of such a DACL lose their inherited flag when the parent folder has no SACL, and the cmdlets stored them as explicit copies. Get-NTFSSecurityDescriptor now reads the DACL in a separate call. - Set-NTFSSecurityDescriptor writes only the sections that changed since they were read; an unchanged descriptor writes nothing (maintainer decision of 2026-10-06). - Clear-NTFSAudit writes nothing for an item without a SACL, and reports an error without the Security privilege (maintainer decision). The regression tests failed before and pass after the fix in Windows PowerShell 5.1 and PowerShell 7, elevated and as a basic user. Co-authored-by: Copilot &lt;223556219+Copilot@users.noreply.github.com&gt; Co-authored-by: AI Assistant &lt;ai@example.com&gt;
4 weeks ago
$privateData = (Get-Module -Name NTFSSecurity).PrivateData
$enablePrivileges = $privateData['EnablePrivileges']
$sidType = [System.Security.Principal.SecurityIdentifier]
# An owner that the user can assign only with the Restore privilege
$trustedInstaller = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'
function Get-RestorePrivilegeState {
(Get-Privileges | Where-Object -Property Privilege -EQ -Value 'Restore').PrivilegeState
}
}
AfterAll {
fix: write only the sections of the security descriptor that a cmdlet changes The access and audit cmdlets wrote the owner of an item back with the entries they changed. For a DACL without the auto-inherit flag, Windows returns the owner and the group even when only the DACL is read, and the cmdlets wrote every section that the descriptor held. Without the Restore privilege, or on a file server that refuses the owner, the write failed with error 1307 (#34). - Add-NTFSAccess, Clear-NTFSAccess, Add-NTFSAudit, and Clear-NTFSAudit read only the DACL or the SACL. FileSystemSecurity2.Write(), Remove-NTFSAccess, and Remove-NTFSAudit write only the sections they read, which also fixes the access inheritance cmdlets. - Read together with the SACL, the inherited entries of such a DACL lose their inherited flag when the parent folder has no SACL, and the cmdlets stored them as explicit copies. Get-NTFSSecurityDescriptor now reads the DACL in a separate call. - Set-NTFSSecurityDescriptor writes only the sections that changed since they were read; an unchanged descriptor writes nothing (maintainer decision of 2026-10-06). - Clear-NTFSAudit writes nothing for an item without a SACL, and reports an error without the Security privilege (maintainer decision). The regression tests failed before and pass after the fix in Windows PowerShell 5.1 and PowerShell 7, elevated and as a basic user. Co-authored-by: Copilot &lt;223556219+Copilot@users.noreply.github.com&gt; Co-authored-by: AI Assistant &lt;ai@example.com&gt;
4 weeks ago
$privateData['EnablePrivileges'] = $enablePrivileges
Pop-Location
Remove-TestSandbox -Sandbox $sandbox
Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue
}
Describe 'Get-NTFSAccess' {
Context 'When a path fails after a readable path' {
# Before 5.0.0, the cmdlet wrote the entries of the previous item again for the failing path.
It 'Should return the entries of the first item once' {
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'Readable' -Directory
$denied = New-TestSandboxItem -Sandbox $sandbox -Name 'Denied'
Block-TestReadPermission -Sandbox $sandbox -Path $denied
$expected = @(Get-NTFSAccess -Path $folder).Count
$entries = @(Get-NTFSAccess -Path $folder, $denied -ErrorAction SilentlyContinue)
@($entries | Where-Object -Property FullName -EQ -Value $folder) | Should -HaveCount $expected
}
}
}
Describe 'Get-NTFSEffectiveAccess' {
BeforeAll {
$effectiveFile = New-TestSandboxItem -Sandbox $sandbox -Name 'Effective'
Assert-TestSandboxPath -Sandbox $sandbox -Path $effectiveFile
$acl = Get-Acl -LiteralPath $effectiveFile
$guests = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList 'S-1-5-32-546'
$acl.AddAccessRule((New-Object -TypeName 'System.Security.AccessControl.FileSystemAccessRule' -ArgumentList (
$guests, [System.Security.AccessControl.FileSystemRights]::FullControl,
[System.Security.AccessControl.AccessControlType]::Deny
)))
Set-Acl -LiteralPath $effectiveFile -AclObject $acl
}
# Before 5.0.0, the warning misspelled the privilege as "Privliege".
It 'Should warn once that the Security privilege is missing' -Skip:$holdsSecurityPrivilege {
Get-NTFSEffectiveAccess -Path $effectiveFile -WarningVariable accessWarnings -WarningAction SilentlyContinue | Out-Null
$accessWarnings | Should -HaveCount 1
$accessWarnings[0].Message | Should -BeExactly 'The user does not hold the Security privilege and might not be able to read the effective permissions.'
}
It 'Should leave out an account without access when -ExcludeNoneAccessEntries is used' {
$result = @(Get-NTFSEffectiveAccess -Path $effectiveFile -Account 'S-1-5-32-546' -ExcludeNoneAccessEntries -WarningAction SilentlyContinue -ErrorAction Stop)
$result | Should -BeNullOrEmpty
}
It 'Should return an account with access when -ExcludeNoneAccessEntries is used' {
$result = @(Get-NTFSEffectiveAccess -Path $effectiveFile -ExcludeNoneAccessEntries -WarningAction SilentlyContinue -ErrorAction Stop)
$result | Should -HaveCount 1
}
It 'Should use the current location when -Path is omitted' {
$result = @(Get-NTFSEffectiveAccess -WarningAction SilentlyContinue -ErrorAction Stop)
$result | Should -HaveCount 1
$result[0].FullName | Should -BeLike ('*\{0}' -f (Split-Path -Path $sandbox -Leaf))
}
It 'Should compute the effective access of a security descriptor' {
$sd = Get-NTFSSecurityDescriptor -Path $effectiveFile
$result = @(Get-NTFSEffectiveAccess -SecurityDescriptor $sd -WarningAction SilentlyContinue -ErrorAction Stop)
$result | Should -HaveCount 1
$result[0].FullName | Should -Be $effectiveFile
}
fix: drive roots, audit descriptors, -WhatIf conflicts, and small items - #41: for the root of a drive, the cmdlets read and changed the security descriptor of the drive, a device object. FileSystemSecurity2 now routes drive roots through the path-based AlphaFS methods, which keep the trailing backslash; the removal and inheritance helpers use it too. - #109: Add-, Remove-, and Clear-NTFSAudit report a security descriptor without the audit entries like Get-NTFSAudit, through one helper, and Get-NTFSEffectiveAccess names the cause that Windows reported instead of a missing Security privilege. - #108: Copy-Item2 and Move-Item2 check the destination only for an operation that runs; with -WhatIf, a verbose message names the conflict. - #111: Disable-Privileges skips the privileges that the token doesn&#39;t hold, the privilege messages are spelled right, and Get-FileHash2 declares the type name of its objects; 05-Releasing.md documents the release metadata tests. - rc3 review leftovers: Remove-NTFSAudit writes nothing for an item without a SACL, the owner retry of Set-NTFSSecurityDescriptor restores the previous owner in a finally block and keeps an owner that the descriptor sets, and FileSystemSecurity2.Write with another item writes only the sections that were read. Each fix has a test that failed first, in Windows PowerShell 5.1 and PowerShell 7; writing a drive root was checked once on a temporary VHD. Co-authored-by: Copilot &lt;223556219+Copilot@users.noreply.github.com&gt; Co-authored-by: AI Assistant &lt;ai@example.com&gt;
4 weeks ago
Context 'When the effective access cannot be calculated' {
BeforeAll {
$privateData['EnablePrivileges'] = $false
}
AfterAll {
$privateData['EnablePrivileges'] = $enablePrivileges
}
# Before 5.0.0-rc4, the cmdlet blamed a missing Security privilege for every failure while the privilege
# wasn't enabled (#109). A security descriptor without an owner is such a failure: the DACL of the file has the
# auto-inherit flag since Set-Acl wrote it, so Windows returns no owner when only the DACL is read.
It 'Should name the cause in the error, not the Security privilege' {
(Get-Privileges | Where-Object -Property Privilege -EQ -Value 'Security').PrivilegeState | Should -Not -Be 'Enabled'
$sd = New-Object -TypeName 'Security2.FileSystemSecurity2' -ArgumentList (
(Get-Item2 -Path $effectiveFile), [System.Security.AccessControl.AccessControlSections]::Access
)
$sd.SecurityDescriptor.GetOwner($sidType) | Should -BeNullOrEmpty
Get-NTFSEffectiveAccess -SecurityDescriptor $sd -ErrorVariable accessErrors -ErrorAction SilentlyContinue -WarningAction SilentlyContinue | Out-Null
$accessErrors | Should -HaveCount 1
$accessErrors[0].FullyQualifiedErrorId | Should -BeLike 'GetEffectiveAccessError,*'
$accessErrors[0].Exception.Message | Should -Not -BeLike '*Enable-Privileges*'
fix: drive roots, audit descriptors, -WhatIf conflicts, and small items - #41: for the root of a drive, the cmdlets read and changed the security descriptor of the drive, a device object. FileSystemSecurity2 now routes drive roots through the path-based AlphaFS methods, which keep the trailing backslash; the removal and inheritance helpers use it too. - #109: Add-, Remove-, and Clear-NTFSAudit report a security descriptor without the audit entries like Get-NTFSAudit, through one helper, and Get-NTFSEffectiveAccess names the cause that Windows reported instead of a missing Security privilege. - #108: Copy-Item2 and Move-Item2 check the destination only for an operation that runs; with -WhatIf, a verbose message names the conflict. - #111: Disable-Privileges skips the privileges that the token doesn&#39;t hold, the privilege messages are spelled right, and Get-FileHash2 declares the type name of its objects; 05-Releasing.md documents the release metadata tests. - rc3 review leftovers: Remove-NTFSAudit writes nothing for an item without a SACL, the owner retry of Set-NTFSSecurityDescriptor restores the previous owner in a finally block and keeps an owner that the descriptor sets, and FileSystemSecurity2.Write with another item writes only the sections that were read. Each fix has a test that failed first, in Windows PowerShell 5.1 and PowerShell 7; writing a drive root was checked once on a temporary VHD. Co-authored-by: Copilot &lt;223556219+Copilot@users.noreply.github.com&gt; Co-authored-by: AI Assistant &lt;ai@example.com&gt;
4 weeks ago
}
}
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' {
# Before 5.0.0, a path with braces stopped the cmdlet with a FormatException (#3).
It 'Should read a folder whose name contains braces' {
$braces = Join-Path -Path $sandbox -ChildPath ('{{Braces}}-{0}' -f [guid]::NewGuid().ToString('N').Substring(0, 8))
Assert-TestSandboxPath -Sandbox $sandbox -Path $braces
[IO.Directory]::CreateDirectory($braces) | Out-Null
{ Get-NTFSOrphanedAccess -Path $braces -ErrorAction Stop } | Should -Not -Throw
}
BeforeAll {
$orphanedFile = New-TestSandboxItem -Sandbox $sandbox -Name 'Orphaned'
Assert-TestSandboxPath -Sandbox $sandbox -Path $orphanedFile
$acl = Get-Acl -LiteralPath $orphanedFile
foreach ($sid in 'S-1-5-21-1-2-3-1001', 'S-1-5-21-1-2-3-1002') {
$acl.AddAccessRule((New-Object -TypeName 'System.Security.AccessControl.FileSystemAccessRule' -ArgumentList (
(New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList $sid),
[System.Security.AccessControl.FileSystemRights]::ReadData, [System.Security.AccessControl.AccessControlType]::Allow
)))
}
Set-Acl -LiteralPath $orphanedFile -AclObject $acl
}
It 'Should return the entries whose account cannot be resolved' {
@(Get-NTFSOrphanedAccess -Path $orphanedFile) | Should -HaveCount 2
}
It 'Should return only the entries of -Account' {
$result = @(Get-NTFSOrphanedAccess -Path $orphanedFile -Account 'S-1-5-21-1-2-3-1002')
$result | Should -HaveCount 1
$result[0].Account.Sid | Should -Be 'S-1-5-21-1-2-3-1002'
}
It 'Should read the entries of a security descriptor' {
$sd = Get-NTFSSecurityDescriptor -Path $orphanedFile
$result = @(Get-NTFSOrphanedAccess -SecurityDescriptor $sd)
$result | Should -HaveCount 2
$result | ForEach-Object -Process { $_.FullName | Should -Be $orphanedFile }
}
}
Describe 'Get-NTFSSimpleAccess' {
BeforeAll {
$simpleFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'Simple' -Directory
Assert-TestSandboxPath -Sandbox $sandbox -Path $simpleFolder
$acl = Get-Acl -LiteralPath $simpleFolder
$acl.AddAccessRule((New-Object -TypeName 'System.Security.AccessControl.FileSystemAccessRule' -ArgumentList (
(New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList 'S-1-1-0'),
[System.Security.AccessControl.FileSystemRights]::ReadData, [System.Security.AccessControl.AccessControlType]::Allow
)))
Set-Acl -LiteralPath $simpleFolder -AclObject $acl
}
It 'Should return only the entries of -Account' {
$result = @(Get-NTFSSimpleAccess -Path $simpleFolder -Account 'S-1-1-0' -IncludeRootFolder:$false)
$result | Should -Not -BeNullOrEmpty
$result | ForEach-Object -Process { $_.Identity.Sid | Should -Be 'S-1-1-0' }
}
It 'Should read the entries of a security descriptor' {
$sd = Get-NTFSSecurityDescriptor -Path $simpleFolder
$result = @(Get-NTFSSimpleAccess -SecurityDescriptor $sd)
$result | Should -Not -BeNullOrEmpty
$result | ForEach-Object -Process { $_.FullName | Should -Be $simpleFolder }
}
It 'Should show the entries as a table with the account, the rights, and the type' {
$text = Get-NTFSSimpleAccess -Path $simpleFolder -IncludeRootFolder:$false | Out-String -Width 200
$text | Should -Match 'Account\s+Access Rights\s+Type'
}
Context 'When it reduces the rights of an entry' {
BeforeAll {
# One explicit entry per case, for a SID of its own and with exactly these rights. .NET would add
# Synchronize to an allow entry, so the DACL is set in SDDL.
$rightsFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'SimpleRights' -Directory
$rightsCases = @(
@{ Name = 'ReadData'; Mask = 0x1; Expected = 'Read' }
@{ Name = 'ReadAttributes'; Mask = 0x80; Expected = 'Read' }
@{ Name = 'Traverse'; Mask = 0x20; Expected = 'Read' }
@{ Name = 'ReadPermissions'; Mask = 0x20000; Expected = 'Read' }
@{ Name = 'CreateFiles'; Mask = 0x2; Expected = 'Write' }
@{ Name = 'AppendData'; Mask = 0x4; Expected = 'Write' }
@{ Name = 'WriteAttributes'; Mask = 0x100; Expected = 'Write' }
@{ Name = 'ChangePermissions'; Mask = 0x40000; Expected = 'Write' }
@{ Name = 'TakeOwnership'; Mask = 0x80000; Expected = 'Write' }
@{ Name = 'Delete'; Mask = 0x10000; Expected = 'Delete' }
@{ Name = 'DeleteSubdirectoriesAndFiles'; Mask = 0x40; Expected = 'Delete' }
@{ Name = 'Modify'; Mask = 0x1301BF; Expected = 'Read, Write, Delete' }
@{ Name = 'FullControl'; Mask = 0x1F01FF; Expected = 'Read, Write, Delete' }
)
$rightsSid = @{}
$entries = for ($i = 0; $i -lt $rightsCases.Count; $i++) {
$sid = 'S-1-5-21-1-2-3-{0}' -f (3001 + $i)
$rightsSid[$rightsCases[$i].Name] = $sid
'(A;;0x{0:X};;;{1})' -f $rightsCases[$i].Mask, $sid
}
$acl = Get-Acl -LiteralPath $rightsFolder
$acl.SetSecurityDescriptorSddlForm(('D:{0}' -f ($entries -join '')), 'Access')
Set-Acl -LiteralPath $rightsFolder -AclObject $acl
$rightsResult = @(Get-NTFSSimpleAccess -Path $rightsFolder -ExcludeInherited -IncludeRootFolder:$false -ErrorAction Stop)
}
# Before 5.0.0-rc6, an entry with ReadData alone, which .NET never creates but other tools do, became None.
It 'Should reduce <Name> to <Expected>' -ForEach @(
@{ Name = 'ReadData'; Expected = 'Read' }
@{ Name = 'ReadAttributes'; Expected = 'Read' }
@{ Name = 'Traverse'; Expected = 'Read' }
@{ Name = 'ReadPermissions'; Expected = 'Read' }
@{ Name = 'CreateFiles'; Expected = 'Write' }
@{ Name = 'AppendData'; Expected = 'Write' }
@{ Name = 'WriteAttributes'; Expected = 'Write' }
@{ Name = 'ChangePermissions'; Expected = 'Write' }
@{ Name = 'TakeOwnership'; Expected = 'Write' }
@{ Name = 'Delete'; Expected = 'Delete' }
@{ Name = 'DeleteSubdirectoriesAndFiles'; Expected = 'Delete' }
@{ Name = 'Modify'; Expected = 'Read, Write, Delete' }
@{ Name = 'FullControl'; Expected = 'Read, Write, Delete' }
) {
$entry = @($rightsResult | Where-Object -FilterScript { $_.Identity.Sid -eq $rightsSid[$Name] })
$entry | Should -HaveCount 1
$entry[0].AccessRights.ToString() | Should -Be $Expected
}
}
Context 'When it compares folders with their parent folder' {
BeforeAll {
# The parent grants Everyone ReadData to its subfolders and the user Full Control, so that a basic user
# can create the items; the child adds an entry of its own. The child is created after the DACL of the
# parent, so that it inherits only these entries.
$parent = New-TestSandboxItem -Sandbox $sandbox -Name 'SimpleParent' -Directory
$user = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
$acl = Get-Acl -LiteralPath $parent
$acl.SetSecurityDescriptorSddlForm("D:P(A;OICI;0x120089;;;WD)(A;OICI;FA;;;BA)(A;OICI;FA;;;SY)(A;OICI;FA;;;$user)", 'Access')
Set-Acl -LiteralPath $parent -AclObject $acl
$child = Join-Path -Path $parent -ChildPath 'Child'
Assert-TestSandboxPath -Sandbox $sandbox -Path $child
New-Item -ItemType Directory -Path $child | Out-Null
$acl = Get-Acl -LiteralPath $child
$acl.AddAccessRule((New-Object -TypeName 'System.Security.AccessControl.FileSystemAccessRule' -ArgumentList (
(New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList 'S-1-5-21-1-2-3-3101'),
[System.Security.AccessControl.FileSystemRights]::Modify, [System.Security.AccessControl.AccessControlType]::Allow
)))
Set-Acl -LiteralPath $child -AclObject $acl
$childFile = Join-Path -Path $child -ChildPath 'File.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $childFile
Set-Content -LiteralPath $childFile -Value 'File' -ErrorAction Stop
}
It 'Should report all entries of the first folder and of the following folder only what the parent does not cover' {
$result = @(Get-NTFSSimpleAccess -Path $parent, $child -IncludeRootFolder:$false -ErrorAction Stop)
@($result | Where-Object -Property FullName -EQ -Value $parent) | Should -HaveCount 4
$childEntries = @($result | Where-Object -Property FullName -EQ -Value $child)
$childEntries | Should -HaveCount 1
$childEntries[0].Identity.Sid | Should -Be 'S-1-5-21-1-2-3-3101'
}
It 'Should compare the folders from the pipeline in the same way' {
$result = @(Get-Item2 -Path $parent, $child | Get-NTFSSimpleAccess -IncludeRootFolder:$false -ErrorAction Stop)
@($result | Where-Object -Property FullName -EQ -Value $child).Identity.Sid | Should -Be 'S-1-5-21-1-2-3-3101'
}
It 'Should report the parent folder of the first path first by default' {
$result = @(Get-NTFSSimpleAccess -Path $child -ErrorAction Stop)
$result[0].FullName | Should -Be $parent
@($result | Where-Object -Property FullName -EQ -Value $child).Identity.Sid | Should -Be 'S-1-5-21-1-2-3-3101'
}
# Before 5.0.0-rc6, a relative path with a single folder name had no parent folder in the result.
It 'Should report the parent folder of a relative path first by default' {
Push-Location -LiteralPath $parent
try {
$result = @(Get-NTFSSimpleAccess -Path 'Child' -ErrorAction Stop)
}
finally {
Pop-Location
}
$result[0].FullName | Should -Be $parent
@($result | Where-Object -Property FullName -EQ -Value $child).Identity.Sid | Should -Be 'S-1-5-21-1-2-3-3101'
}
It 'Should use the current location without -Path' {
Push-Location -LiteralPath $child
try {
$result = @(Get-NTFSSimpleAccess -IncludeRootFolder:$false -ErrorAction Stop)
}
finally {
Pop-Location
}
$result | ForEach-Object -Process { $_.FullName | Should -Be $child }
}
It 'Should return only the inherited entries with -ExcludeExplicit' {
$result = @(Get-NTFSSimpleAccess -Path $child -ExcludeExplicit -IncludeRootFolder:$false -ErrorAction Stop)
$result | Should -HaveCount 4
$result.Identity.Sid | Should -Not -Contain 'S-1-5-21-1-2-3-3101'
}
It 'Should skip a file without an error' {
$result = @(Get-NTFSSimpleAccess -Path $childFile -IncludeRootFolder:$false -ErrorVariable simpleErrors -ErrorAction SilentlyContinue)
$simpleErrors | Should -BeNullOrEmpty
$result | Should -BeNullOrEmpty
}
It 'Should report the security descriptor of a file' {
$result = @(Get-NTFSSecurityDescriptor -Path $childFile | Get-NTFSSimpleAccess -ErrorAction Stop)
$result | Should -Not -BeNullOrEmpty
$result | ForEach-Object -Process { $_.FullName | Should -Be $childFile }
}
It 'Should write an error for a path that does not exist and continue with the next path' {
$missing = Join-Path -Path $parent -ChildPath 'Missing'
$result = @(Get-NTFSSimpleAccess -Path $missing, $child -IncludeRootFolder:$false -ErrorVariable simpleErrors -ErrorAction SilentlyContinue)
$simpleErrors | Should -HaveCount 1
$simpleErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadError,*'
$result | Should -Not -BeNullOrEmpty
$result | ForEach-Object -Process { $_.FullName | Should -Be $child }
}
}
}
Describe 'Remove-NTFSAccess' {
Context 'When a path does not exist' {
BeforeAll {
$missing = Join-Path -Path $sandbox -ChildPath 'Missing.txt'
}
# Before 5.0.0, the cmdlet went on with the missing item and wrote a second, misleading RemoveAceError.
It 'Should write only the read error' {
Remove-NTFSAccess -Path $missing -Account 'Everyone' -AccessRights ReadData -ErrorVariable removeErrors -ErrorAction SilentlyContinue
$removeErrors | Should -HaveCount 1
$removeErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadFileError,*'
}
It 'Should not stop with -PassThru' {
{ Remove-NTFSAccess -Path $missing -Account 'Everyone' -AccessRights ReadData -PassThru -ErrorAction SilentlyContinue } |
Should -Not -Throw
}
}
Context 'When the entries cannot be changed' {
# Before 5.0.0, -PassThru returned the unchanged entries of the item after the error.
It 'Should write an error and return nothing with -PassThru' -Skip:$canBypassWriteDeny {
$protected = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveProtected'
Assert-TestSandboxPath -Sandbox $sandbox -Path $protected
Add-NTFSAccess -Path $protected -Account 'Everyone' -AccessRights ReadData
Block-TestWritePermission -Sandbox $sandbox -Path $protected
$result = @(Remove-NTFSAccess -Path $protected -Account 'Everyone' -AccessRights ReadData -PassThru -ErrorVariable removeErrors -ErrorAction SilentlyContinue)
$removeErrors | Should -HaveCount 1
$removeErrors[0].FullyQualifiedErrorId | Should -BeLike 'RemoveAceError,*'
$result | Should -BeNullOrEmpty
}
}
fix: write only the sections of the security descriptor that a cmdlet changes The access and audit cmdlets wrote the owner of an item back with the entries they changed. For a DACL without the auto-inherit flag, Windows returns the owner and the group even when only the DACL is read, and the cmdlets wrote every section that the descriptor held. Without the Restore privilege, or on a file server that refuses the owner, the write failed with error 1307 (#34). - Add-NTFSAccess, Clear-NTFSAccess, Add-NTFSAudit, and Clear-NTFSAudit read only the DACL or the SACL. FileSystemSecurity2.Write(), Remove-NTFSAccess, and Remove-NTFSAudit write only the sections they read, which also fixes the access inheritance cmdlets. - Read together with the SACL, the inherited entries of such a DACL lose their inherited flag when the parent folder has no SACL, and the cmdlets stored them as explicit copies. Get-NTFSSecurityDescriptor now reads the DACL in a separate call. - Set-NTFSSecurityDescriptor writes only the sections that changed since they were read; an unchanged descriptor writes nothing (maintainer decision of 2026-10-06). - Clear-NTFSAudit writes nothing for an item without a SACL, and reports an error without the Security privilege (maintainer decision). The regression tests failed before and pass after the fix in Windows PowerShell 5.1 and PowerShell 7, elevated and as a basic user. Co-authored-by: Copilot &lt;223556219+Copilot@users.noreply.github.com&gt; Co-authored-by: AI Assistant &lt;ai@example.com&gt;
4 weeks ago
Context 'When the item has an owner that the user cannot assign' {
BeforeAll {
$privateData['EnablePrivileges'] = $false
}
AfterAll {
$privateData['EnablePrivileges'] = $enablePrivileges
}
# Before 5.0.0-rc3, the cmdlet read only the DACL, but wrote the owner that Windows returns with a DACL without
# the auto-inherit flag, which Windows refuses without the Restore privilege (#34). Any write of the DACL adds
# the flag, so the item keeps its DACL as created and has no explicit entry to remove.
It 'Should write no error and keep the owner' -Skip:(-not $canAssignAnyOwner) {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveOtherOwner'
Set-TestOwner -Sandbox $sandbox -Path $file -Sid $trustedInstaller
Get-RestorePrivilegeState | Should -Be 'Disabled'
Remove-NTFSAccess -Path $file -Account 'Everyone' -AccessRights ReadData -ErrorVariable removeErrors -ErrorAction SilentlyContinue
$removeErrors | Should -BeNullOrEmpty
(Get-Acl -LiteralPath $file).GetOwner($sidType).Value | Should -Be $trustedInstaller
}
}
Context 'With a generic right' {
# Before 5.0.0, removing an entry with a generic right such as GENERIC_ALL failed with "The value '269484032' is
# not valid", because .NET rebuilds the rule and rejects generic rights (#17). Windows keeps generic rights in
# inherit-only entries of folders.
BeforeAll {
$guests = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-546'
function New-GenericRightFolder {
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Test helper that only writes to the sandbox.'
)]
param ([string] $Entry)
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'Generic' -Directory
Assert-TestSandboxPath -Sandbox $sandbox -Path $folder
$acl = Get-Acl -LiteralPath $folder
$acl.SetSecurityDescriptorSddlForm(($acl.Sddl -replace 'D:(?<flags>[A-Z]*)', ('D:${flags}' + $Entry)))
Set-Acl -LiteralPath $folder -AclObject $acl
$folder
}
function Get-GuestsRule {
param ([string] $Path)
(Get-Acl -LiteralPath $Path).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) |
Where-Object -Property IdentityReference -EQ -Value $guests
}
}
It 'Should remove an inherit-only entry that Get-NTFSAccess returned, and nothing else' -ForEach @(
@{ Case = 'Allow'; Entry = '(A;OICIIO;GA;;;BG)'; Specific = $false }
@{ Case = 'Allow with -RemoveSpecific'; Entry = '(A;OICIIO;GA;;;BG)'; Specific = $true }
@{ Case = 'Deny'; Entry = '(D;OICIIO;GA;;;BG)'; Specific = $false }
) {
$folder = New-GenericRightFolder -Entry $Entry
$before = (Get-Acl -LiteralPath $folder).Sddl
$before | Should -Match ([regex]::Escape($Entry))
Get-NTFSAccess -Path $folder -Account 'S-1-5-32-546' -ExcludeInherited |
Remove-NTFSAccess -RemoveSpecific:$Specific -ErrorVariable removeErrors -ErrorAction SilentlyContinue
$removeErrors | Should -BeNullOrEmpty
(Get-Acl -LiteralPath $folder).Sddl | Should -BeExactly $before.Replace($Entry, '')
}
It 'Should remove only the requested generic right from an entry with two' {
$folder = New-GenericRightFolder -Entry '(A;OICIIO;0x90000000;;;BG)'
Remove-NTFSAccess -Path $folder -Account 'S-1-5-32-546' -AccessRights GenericRead -InheritanceFlags ContainerInherit, ObjectInherit -PropagationFlags InheritOnly -ErrorVariable removeErrors -ErrorAction SilentlyContinue
$removeErrors | Should -BeNullOrEmpty
$rule = Get-GuestsRule -Path $folder
$rule | Should -HaveCount 1
[int] $rule.FileSystemRights | Should -Be 0x10000000
}
# A rule that matches the entry exactly is removed as it is, with the Synchronize right that the module adds
# to an Allow rule; otherwise an entry with only Synchronize would be left behind.
It 'Should remove an entry with GenericAll and Synchronize when -AccessRights names GenericAll' {
$folder = New-GenericRightFolder -Entry '(A;OICIIO;0x10100000;;;BG)'
Remove-NTFSAccess -Path $folder -Account 'S-1-5-32-546' -AccessRights GenericAll -InheritanceFlags ContainerInherit, ObjectInherit -PropagationFlags InheritOnly -ErrorVariable removeErrors -ErrorAction SilentlyContinue
$removeErrors | Should -BeNullOrEmpty
Get-GuestsRule -Path $folder | Should -BeNullOrEmpty
}
}
Context 'With -RemoveSpecific' {
BeforeEach {
$removeFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveSpecific' -Directory
$sd = Get-NTFSSecurityDescriptor -Path $removeFolder
Add-NTFSAccess -SecurityDescriptor $sd -Account 'Everyone' -AccessRights Modify
function Get-EveryoneRule {
$sd.SecurityDescriptor.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) |
Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }
}
}
It 'Should keep an entry that does not match exactly' {
Remove-NTFSAccess -SecurityDescriptor $sd -Account 'Everyone' -AccessRights ReadData -RemoveSpecific
(Get-EveryoneRule).FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::Modify) | Should -BeTrue
}
It 'Should remove an entry that matches exactly' {
Remove-NTFSAccess -SecurityDescriptor $sd -Account 'Everyone' -AccessRights Modify -RemoveSpecific
Get-EveryoneRule | Should -BeNullOrEmpty
}
It 'Should take the rights away from a matching entry without -RemoveSpecific' {
Remove-NTFSAccess -SecurityDescriptor $sd -Account 'Everyone' -AccessRights ReadData
$rule = Get-EveryoneRule
$rule | Should -Not -BeNullOrEmpty
$rule.FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::ReadData) | Should -BeFalse
}
}
}
Describe 'Add-NTFSAccess' {
Context 'When the entries cannot be changed' {
# Before 5.0.0, -PassThru returned the unchanged entries of the item after the error.
It 'Should write an error and return nothing with -PassThru' -Skip:$canBypassWriteDeny {
$protected = New-TestSandboxItem -Sandbox $sandbox -Name 'AddProtected'
Assert-TestSandboxPath -Sandbox $sandbox -Path $protected
Block-TestWritePermission -Sandbox $sandbox -Path $protected
$result = @(Add-NTFSAccess -Path $protected -Account 'Everyone' -AccessRights ReadData -PassThru -ErrorVariable addErrors -ErrorAction SilentlyContinue)
$addErrors | Should -HaveCount 1
$addErrors[0].FullyQualifiedErrorId | Should -BeLike 'AddAceError,*'
$result | Should -BeNullOrEmpty
}
}
fix: write only the sections of the security descriptor that a cmdlet changes The access and audit cmdlets wrote the owner of an item back with the entries they changed. For a DACL without the auto-inherit flag, Windows returns the owner and the group even when only the DACL is read, and the cmdlets wrote every section that the descriptor held. Without the Restore privilege, or on a file server that refuses the owner, the write failed with error 1307 (#34). - Add-NTFSAccess, Clear-NTFSAccess, Add-NTFSAudit, and Clear-NTFSAudit read only the DACL or the SACL. FileSystemSecurity2.Write(), Remove-NTFSAccess, and Remove-NTFSAudit write only the sections they read, which also fixes the access inheritance cmdlets. - Read together with the SACL, the inherited entries of such a DACL lose their inherited flag when the parent folder has no SACL, and the cmdlets stored them as explicit copies. Get-NTFSSecurityDescriptor now reads the DACL in a separate call. - Set-NTFSSecurityDescriptor writes only the sections that changed since they were read; an unchanged descriptor writes nothing (maintainer decision of 2026-10-06). - Clear-NTFSAudit writes nothing for an item without a SACL, and reports an error without the Security privilege (maintainer decision). The regression tests failed before and pass after the fix in Windows PowerShell 5.1 and PowerShell 7, elevated and as a basic user. Co-authored-by: Copilot &lt;223556219+Copilot@users.noreply.github.com&gt; Co-authored-by: AI Assistant &lt;ai@example.com&gt;
4 weeks ago
Context 'When the item has an owner that the user cannot assign' {
BeforeAll {
$privateData['EnablePrivileges'] = $false
}
AfterAll {
$privateData['EnablePrivileges'] = $enablePrivileges
}
# Before 5.0.0-rc3, the cmdlet wrote the unchanged owner back. Without the Restore privilege, Windows refuses
# an owner that the user cannot assign, like a file server that refuses the owner (#34): (1307) This security
# ID may not be assigned as the owner of this object.
It 'Should add the entry and keep the owner' -Skip:(-not $canAssignAnyOwner) {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'OtherOwner'
Set-TestOwner -Sandbox $sandbox -Path $file -Sid $trustedInstaller
Get-RestorePrivilegeState | Should -Be 'Disabled'
Add-NTFSAccess -Path $file -Account 'Everyone' -AccessRights ReadData -ErrorVariable addErrors -ErrorAction SilentlyContinue
$addErrors | Should -BeNullOrEmpty
$acl = Get-Acl -LiteralPath $file
$acl.GetOwner($sidType).Value | Should -Be $trustedInstaller
@($acl.GetAccessRules($true, $false, $sidType) | Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) |
Should -HaveCount 1
}
}
Context 'With inherited entries' {
# Before 5.0.0-rc3, the cmdlet read the DACL together with the SACL when the process held the Security
# privilege. When the folder has no SACL, Windows then returns the inherited entries of a DACL without the
# auto-inherit flag, such as that of a file in the temp folder of the user, without their inherited flag, and
# the cmdlet wrote them back as explicit copies.
It 'Should add one explicit entry and keep the inherited entries inherited' -Skip:(-not $holdsSecurityPrivilege) {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'Inherited'
Assert-TestSandboxPath -Sandbox $sandbox -Path $file
$inheritedCount = @((Get-Acl -LiteralPath $file).GetAccessRules($false, $true, $sidType)).Count
$inheritedCount | Should -BeGreaterThan 0
Add-NTFSAccess -Path $file -Account 'Everyone' -AccessRights ReadData
$acl = Get-Acl -LiteralPath $file
@($acl.GetAccessRules($true, $false, $sidType)) | Should -HaveCount 1
@($acl.GetAccessRules($false, $true, $sidType)) | Should -HaveCount $inheritedCount
}
}
}
Describe 'Security descriptor parameter sets' {
BeforeAll {
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'ParameterSets' -Directory
}
# Before 5.0.0, PowerShell could not choose between the SDSimple and SDComplex parameter sets.
It '<_> should accept -SecurityDescriptor without -AppliesTo or the flag parameters' -ForEach @(
'Add-NTFSAccess', 'Remove-NTFSAccess'
) {
$sd = Get-NTFSSecurityDescriptor -Path $folder
{ & $_ -SecurityDescriptor $sd -Account 'Everyone' -AccessRights ReadData -ErrorAction Stop } | Should -Not -Throw
}
# A descriptor from Get-NTFSSecurityDescriptor contains the audit entries only with the Security privilege.
It '<_> should accept -SecurityDescriptor without -AppliesTo or the flag parameters' -Skip:(-not $holdsSecurityPrivilege) -ForEach @(
'Add-NTFSAudit', 'Remove-NTFSAudit'
) {
$sd = Get-NTFSSecurityDescriptor -Path $folder
{ & $_ -SecurityDescriptor $sd -Account 'Everyone' -AccessRights ReadData -ErrorAction Stop } | Should -Not -Throw
}
It 'Add-NTFSAccess should apply the entry to the folder, its subfolders, and files by default' {
$sd = Get-NTFSSecurityDescriptor -Path $folder
Add-NTFSAccess -SecurityDescriptor $sd -Account 'Everyone' -AccessRights ReadData
$rule = $sd.SecurityDescriptor.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) |
Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }
$rule.InheritanceFlags | Should -Be ([System.Security.AccessControl.InheritanceFlags] 'ContainerInherit, ObjectInherit')
$rule.PropagationFlags | Should -Be ([System.Security.AccessControl.PropagationFlags]::None)
}
It 'Add-NTFSAccess should still take -AppliesTo for a security descriptor' {
$sd = Get-NTFSSecurityDescriptor -Path $folder
Add-NTFSAccess -SecurityDescriptor $sd -Account 'Everyone' -AccessRights ReadData -AppliesTo ThisFolderOnly
$rule = $sd.SecurityDescriptor.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) |
Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }
$rule.InheritanceFlags | Should -Be ([System.Security.AccessControl.InheritanceFlags]::None)
}
}
Describe 'Clear-NTFSAccess' {
Context 'With -DisableInheritance' {
# The cmdlet does not copy the inherited entries, so the item is left with an empty DACL. This is the
# documented behavior; Set-NTFSInheritance and Disable-NTFSAccessInheritance keep the entries.
It 'Should leave the item with an empty, protected DACL' {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'ClearAll'
Assert-TestSandboxPath -Sandbox $sandbox -Path $file
Clear-NTFSAccess -Path $file -DisableInheritance
$acl = Get-Acl -LiteralPath $file
$acl.AreAccessRulesProtected | Should -BeTrue
$acl.Access | Should -BeNullOrEmpty
}
}
fix: write only the sections of the security descriptor that a cmdlet changes The access and audit cmdlets wrote the owner of an item back with the entries they changed. For a DACL without the auto-inherit flag, Windows returns the owner and the group even when only the DACL is read, and the cmdlets wrote every section that the descriptor held. Without the Restore privilege, or on a file server that refuses the owner, the write failed with error 1307 (#34). - Add-NTFSAccess, Clear-NTFSAccess, Add-NTFSAudit, and Clear-NTFSAudit read only the DACL or the SACL. FileSystemSecurity2.Write(), Remove-NTFSAccess, and Remove-NTFSAudit write only the sections they read, which also fixes the access inheritance cmdlets. - Read together with the SACL, the inherited entries of such a DACL lose their inherited flag when the parent folder has no SACL, and the cmdlets stored them as explicit copies. Get-NTFSSecurityDescriptor now reads the DACL in a separate call. - Set-NTFSSecurityDescriptor writes only the sections that changed since they were read; an unchanged descriptor writes nothing (maintainer decision of 2026-10-06). - Clear-NTFSAudit writes nothing for an item without a SACL, and reports an error without the Security privilege (maintainer decision). The regression tests failed before and pass after the fix in Windows PowerShell 5.1 and PowerShell 7, elevated and as a basic user. Co-authored-by: Copilot &lt;223556219+Copilot@users.noreply.github.com&gt; Co-authored-by: AI Assistant &lt;ai@example.com&gt;
4 weeks ago
Context 'When the item has an owner that the user cannot assign' {
BeforeAll {
$privateData['EnablePrivileges'] = $false
}
AfterAll {
$privateData['EnablePrivileges'] = $enablePrivileges
}
# Before 5.0.0-rc3, the cmdlet wrote the unchanged owner back, which Windows refuses without the Restore
# privilege (#34). For a DACL without the auto-inherit flag, such as that of a new file in the temp folder of
# the user, Windows returns the owner even when only the DACL is read. Any write of the DACL adds the flag, so
# the item keeps its DACL as created.
It 'Should write no error and keep the owner' -Skip:(-not $canAssignAnyOwner) {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'ClearOtherOwner'
Set-TestOwner -Sandbox $sandbox -Path $file -Sid $trustedInstaller
Get-RestorePrivilegeState | Should -Be 'Disabled'
Clear-NTFSAccess -Path $file -ErrorVariable clearErrors -ErrorAction SilentlyContinue
$clearErrors | Should -BeNullOrEmpty
$acl = Get-Acl -LiteralPath $file
$acl.GetOwner($sidType).Value | Should -Be $trustedInstaller
@($acl.GetAccessRules($true, $false, $sidType)) | Should -BeNullOrEmpty
}
}
}