From c4b422ae97707c773e9a63e2222fd61e6cc6daad Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 09:32:32 +0000 Subject: [PATCH] fix: report ReadData and the parent of a relative path in Get-NTFSSimpleAccess Two deviations from the cmdlet page, found by new tests from its contract: - An entry that grants only ReadData became None: the reduction tested the combined Read mask, and nothing mapped ReadData alone. .NET adds Synchronize to every allow entry, which hid it; other tools write such entries. - With -IncludeRootFolder, on by default, a relative path with a single folder name had no parent folder in the result, because the parent was taken from the path before it was resolved. The tests also cover the rights reduction, the comparison of folders with their parent, the pipeline, the current location, -ExcludeExplicit, files, and missing paths, elevated and as a basic user in both editions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- CHANGELOG.md | 4 + Docs/Cmdlets/Get-NTFSSimpleAccess.md | 2 +- .../SimpleAccessCmdlets.cs | 4 +- NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml | 2 +- .../FileSystem/SimpleFileSystemAccessRule.cs | 4 + Tests/Access.Tests.ps1 | 161 ++++++++++++++++++ 6 files changed, 174 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d6048a..871d0ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -283,5 +283,9 @@ The format is based on another command in the pipeline, such as `Disable-Privileges`, had disabled one of them; a privilege that they can't disable now gives a warning, and they still disable the others +- Fix `Get-NTFSSimpleAccess`, which showed no rights for an entry that + grants only `ReadData`, and which left out the parent folder of a + relative path with a single folder name, although `-IncludeRootFolder` is + on by default [Unreleased]: https://github.com/raandree/NTFSSecurity/compare/4.2.6...HEAD diff --git a/Docs/Cmdlets/Get-NTFSSimpleAccess.md b/Docs/Cmdlets/Get-NTFSSimpleAccess.md index 5581768..a038b81 100644 --- a/Docs/Cmdlets/Get-NTFSSimpleAccess.md +++ b/Docs/Cmdlets/Get-NTFSSimpleAccess.md @@ -190,7 +190,7 @@ When the module setting `EnablePrivileges` is `$true` (the default in the `Priva The simplified rights hide which exact rights an account holds. Use `Get-NTFSAccess` when you need the full access control entry, and `Get-NTFSEffectiveAccess` when you need the rights that result from all entries together. -Before 5.0.0, the cmdlet ignored `-Account` and `-SecurityDescriptor`, and its output had no table view. +Before 5.0.0, the cmdlet ignored `-Account` and `-SecurityDescriptor`, and its output had no table view. It also showed no rights for an entry that grants only `ReadData`, which other tools than .NET create, and it left out the parent folder of a relative path with a single folder name, such as `Data`. ## RELATED LINKS diff --git a/NTFSSecurity/SimpleAccessCmdlets/SimpleAccessCmdlets.cs b/NTFSSecurity/SimpleAccessCmdlets/SimpleAccessCmdlets.cs index 43970ec..8cb56ff 100644 --- a/NTFSSecurity/SimpleAccessCmdlets/SimpleAccessCmdlets.cs +++ b/NTFSSecurity/SimpleAccessCmdlets/SimpleAccessCmdlets.cs @@ -47,7 +47,9 @@ namespace NTFSSecurity //as this cmdlet retreives also the current working folder to show the permissions. if (includeRootFolder & isFirstFolder) { - string rootPath = System.IO.Path.GetDirectoryName(paths[0]); + // Resolved first, so that a relative path with a single folder name has the current location as its + // parent folder; before 5.0.0-rc6, such a path had no parent folder in the result. + string rootPath = System.IO.Path.GetDirectoryName(GetRelativePath(paths[0])); if (!string.IsNullOrEmpty(rootPath)) { diff --git a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml index 9dec184..8dcd09f 100644 --- a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml +++ b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml @@ -6624,7 +6624,7 @@ PS C:\Data> Get-NTFSSecurityDescriptor 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. The simplified rights hide which exact rights an account holds. Use `Get-NTFSAccess` when you need the full access control entry, and `Get-NTFSEffectiveAccess` when you need the rights that result from all entries together. - Before 5.0.0, the cmdlet ignored `-Account` and `-SecurityDescriptor`, and its output had no table view. + Before 5.0.0, the cmdlet ignored `-Account` and `-SecurityDescriptor`, and its output had no table view. It also showed no rights for an entry that grants only `ReadData`, which other tools than .NET create, and it left out the parent folder of a relative path with a single folder name, such as `Data`. diff --git a/Security2/FileSystem/SimpleFileSystemAccessRule.cs b/Security2/FileSystem/SimpleFileSystemAccessRule.cs index 6c6e47c..ddbddd8 100644 --- a/Security2/FileSystem/SimpleFileSystemAccessRule.cs +++ b/Security2/FileSystem/SimpleFileSystemAccessRule.cs @@ -42,6 +42,10 @@ namespace Security2 if ((accessRights & FileSystemRights2.Read) == FileSystemRights2.Read) { result |= SimpleFileSystemAccessRights.Read; } + // An entry with ReadData alone, which other tools than .NET create, was None before 5.0.0-rc6. + if ((accessRights & FileSystemRights2.ReadData) == FileSystemRights2.ReadData) + { result |= SimpleFileSystemAccessRights.Read; } + if ((accessRights & FileSystemRights2.CreateFiles) == FileSystemRights2.CreateFiles) { result |= SimpleFileSystemAccessRights.Write; } diff --git a/Tests/Access.Tests.ps1 b/Tests/Access.Tests.ps1 index fee022f..9df8bb3 100644 --- a/Tests/Access.Tests.ps1 +++ b/Tests/Access.Tests.ps1 @@ -228,6 +228,167 @@ Describe 'Get-NTFSSimpleAccess' { $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 to ' -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' {