From b14c90b038aa608cbec437584f9652c03dfb702e Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 12:42:48 +0000 Subject: [PATCH] fix: guard remaining object and cmdlet behavior paths Reproduce and fix public rule paths, simplified audit comparisons and ReadData conversion, and boxed privilege equality. Add behavior guards for descriptor inheritance, unresolved identities, audit capability and recursive denial. Freeze this source for Release matrix measurement; final gate evidence and independent review follow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- .memory-bank/activeContext.md | 28 +- CHANGELOG.md | 10 + Docs/FAQ.md | 18 ++ ProcessPrivileges/PrivilegeAndAttributes.cs | 2 +- .../FileSystemAccessRule2.cs | 1 + .../FileSystemAuditRule2.cs | 1 + .../FileSystem/SimpleFileSystemAuditRule.cs | 5 +- Tests/Access.Tests.ps1 | 35 +++ Tests/Audit.Tests.ps1 | 101 +++++++ Tests/Inheritance.Tests.ps1 | 78 ++++++ Tests/ItemCmdlets.Tests.ps1 | 33 +++ Tests/ObjectApis.Tests.ps1 | 263 ++++++++++++++++++ 12 files changed, 561 insertions(+), 14 deletions(-) create mode 100644 Tests/ObjectApis.Tests.ps1 diff --git a/.memory-bank/activeContext.md b/.memory-bank/activeContext.md index 52c5dab..ce702e4 100644 --- a/.memory-bank/activeContext.md +++ b/.memory-bank/activeContext.md @@ -9,11 +9,15 @@ source: current task evidence ## Current focus -Quality-gate follow-up is implemented and validated locally on -`ai/quality-gate-coverage`, based on `d25647d` of #116. Code/test baseline -`3442194`, lab regression/acceptance `7594e0c`; final records follow. -No remote mutation. Architecture/cmdlet-design choices remain deferred; -Decision 22 is still proposed. Stable 5.0.0 is not ready (Decision 21). +Handoff 1 is in progress on `ai/quality-gate-paths`, from reviewed #117 +head `f11ff41`. Both stacked PRs are open and green; rc6 remains the latest +published candidate and 4.2.6 the stable Gallery version. Public rule-path, +simplified-audit, and boxed privilege-comparison defects were reproduced +and fixed test-first. New descriptor, native-identity, audit-capability and +recursive-denial guards pass focused checks. Frozen full-suite coverage, +complete path explanations and the requested independent review follow. +The shared lab and remotes are unchanged. Decisions 21/22 and stable 5.0.0 +remain gated; Decision 22 is proposed, not accepted. ## Evidence @@ -56,10 +60,10 @@ Decision 22 is still proposed. Stable 5.0.0 is not ready (Decision 21). ## Next step -1. Maintainer pushes/reviews this follow-up; retain separate commits and - stacked-PR merge order (15). #116's Decision 22 review remains required. -2. Integrate and pass CI, then publish/test the next candidate package. -3. Close the remaining-path inventory (918 points, 562 for finer review), - decide/provision the OS matrix, obtain or explicitly accept #34 feedback. -4. Only then release 5.0.0 through documented CI steps; never claim the - current coverage percentage alone meets the quality gate. +1. Finish Handoff 1: freeze Release source, prove characterization guards, + run all four configurations, classify every refreshed gap and review. +2. Send code fixes and persistent evidence to gate 3 before publication; + repeat affected packaged acceptance after integration. No local upload. +3. Retain stacked-PR order (15), obtain Decision 22 review, finish the OS + matrix and obtain or explicitly accept #34 feedback through other gates. +4. Do not release stable 5.0.0 or equate a percentage with gate closure. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b9045b..3c79c5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,16 @@ The format is based on ### Fixed +- Retain the supplied path in the public access- and audit-rule constructors + so their `FullName`, `Name`, and simplified audit conversions identify + the item +- Reduce `ReadData` to `Read` in simplified audit entries, and compare them + with audit entries rather than access entries, preserving equality with + themselves and with equivalent simplified audit objects +- Compare boxed privilege output values by their privilege and attributes; + the object overload rejected privilege values and recursively compared + an attributes enum instead + - Fix `Get-Help`, which showed only the syntax: ship the help file `en-US\NTFSSecurity.dll-Help.xml` generated from the cmdlet documentation, including the links that `Get-Help -Online` opens, instead of the outdated diff --git a/Docs/FAQ.md b/Docs/FAQ.md index 1232611..6ebfeb4 100644 --- a/Docs/FAQ.md +++ b/Docs/FAQ.md @@ -93,3 +93,21 @@ Compare-Object -ReferenceObject (Get-NTFSAccess -Path C:\Data\A) -DifferenceObje The same works for the entries of `Get-NTFSAudit`, with `AuditFlags` in place of `AccessControlType`. See [Get-NTFSAccess](Cmdlets/Get-NTFSAccess.md). + +## How do the public object APIs compare and convert entries? + +The public `FileSystemAccessRule2` and `FileSystemAuditRule2` constructors +that take a .NET rule and a string path retain that path in `FullName` and +its last component in `Name`. They do not read or change the item. This is +useful when an application constructs a rule before replaying it through +the public rule helpers. + +`ToSimpleFileSystemAuditRule2()` retains the path and account and reduces +`ReadData` to `Read`, like the simplified access-rule helper. Simplified +audit objects compare only with other simplified audit objects; they are +not equal to access objects. This does not change the reference-based +comparison of the full access and audit entries described above. + +The values returned by `Get-Privileges` compare by `Privilege` and +`PrivilegeAttributes`. Typed and boxed .NET comparisons agree, and an +unrelated object is not equal to a privilege value. diff --git a/ProcessPrivileges/PrivilegeAndAttributes.cs b/ProcessPrivileges/PrivilegeAndAttributes.cs index 065ade9..6ed6684 100644 --- a/ProcessPrivileges/PrivilegeAndAttributes.cs +++ b/ProcessPrivileges/PrivilegeAndAttributes.cs @@ -83,7 +83,7 @@ namespace ProcessPrivileges /// Value indicating whether this instance and a specified object are equal. public override bool Equals(object obj) { - return obj is PrivilegeAttributes ? this.Equals((PrivilegeAttributes)obj) : false; + return obj is PrivilegeAndAttributes ? this.Equals((PrivilegeAndAttributes)obj) : false; } /// Indicates whether this instance and another instance are equal. diff --git a/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.cs b/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.cs index bcba319..4b1a200 100644 --- a/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.cs +++ b/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.cs @@ -37,6 +37,7 @@ namespace Security2 public FileSystemAccessRule2(FileSystemAccessRule fileSystemAccessRule, string path) { this.fileSystemAccessRule = fileSystemAccessRule; + this.fullName = path; } public static implicit operator FileSystemAccessRule(FileSystemAccessRule2 ace2) diff --git a/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.cs b/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.cs index f8c729f..8699568 100644 --- a/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.cs +++ b/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.cs @@ -37,6 +37,7 @@ namespace Security2 public FileSystemAuditRule2(FileSystemAuditRule fileSystemAuditRule, string path) { this.fileSystemAuditRule = fileSystemAuditRule; + this.fullName = path; } #region Conversion diff --git a/Security2/FileSystem/SimpleFileSystemAuditRule.cs b/Security2/FileSystem/SimpleFileSystemAuditRule.cs index fbf2a58..d399186 100644 --- a/Security2/FileSystem/SimpleFileSystemAuditRule.cs +++ b/Security2/FileSystem/SimpleFileSystemAuditRule.cs @@ -42,6 +42,9 @@ namespace Security2 if ((accessRights & FileSystemRights2.Read) == FileSystemRights2.Read) { result |= SimpleFileSystemAccessRights.Read; } + if ((accessRights & FileSystemRights2.ReadData) == FileSystemRights2.ReadData) + { result |= SimpleFileSystemAccessRights.Read; } + if ((accessRights & FileSystemRights2.CreateFiles) == FileSystemRights2.CreateFiles) { result |= SimpleFileSystemAccessRights.Write; } @@ -109,7 +112,7 @@ namespace Security2 public override bool Equals(object obj) { - var compareObject = obj as SimpleFileSystemAccessRule; + var compareObject = obj as SimpleFileSystemAuditRule; if (compareObject == null) { diff --git a/Tests/Access.Tests.ps1 b/Tests/Access.Tests.ps1 index 32d0fa5..0c3efdc 100644 --- a/Tests/Access.Tests.ps1 +++ b/Tests/Access.Tests.ps1 @@ -955,3 +955,38 @@ Describe 'InheritedFrom of access entries' { } } } +Describe 'Get-NTFSEffectiveAccess for an unresolved identity' { + It 'Should report the native identity error for each and return no access entry' -ForEach @( + @{ Source = 'Path' } + @{ Source = 'SecurityDescriptor' } + ) { + $first = New-TestSandboxItem -Sandbox $sandbox -Name 'UnresolvedFirst' + $next = New-TestSandboxItem -Sandbox $sandbox -Name 'UnresolvedNext' + $identity = [Security2.IdentityReference2] 'S-1-5-21-1-2-3-1001' + $identity.AccountName | Should -BeNullOrEmpty + $identity.LastError | Should -Not -BeNullOrEmpty + $before = @((Get-Acl -LiteralPath $first).Sddl, (Get-Acl -LiteralPath $next).Sddl) + $parameters = @{ Account = $identity; WarningAction = 'SilentlyContinue'; ErrorAction = 'SilentlyContinue' } + if ($Source -eq 'Path') { + $parameters.Path = @($first, $next) + } + else { + $parameters.SecurityDescriptor = @(Get-NTFSSecurityDescriptor -Path $first, $next) + } + + $result = @(Get-NTFSEffectiveAccess @parameters -ErrorVariable accessErrors) + + $result | Should -BeNullOrEmpty + $accessErrors | Should -HaveCount 2 + for ($index = 0; $index -lt 2; $index++) { + $accessErrors[$index].FullyQualifiedErrorId | Should -BeLike 'GetEffectiveAccessError,*' + $accessErrors[$index].CategoryInfo.Category | Should -Be 'ReadError' + $accessErrors[$index].TargetObject.FullName | Should -BeExactly @($first, $next)[$index] + $cause = $accessErrors[$index].Exception.GetBaseException() + $cause | Should -BeOfType [System.ComponentModel.Win32Exception] + $cause.NativeErrorCode | Should -Be 1332 + } + (Get-Acl -LiteralPath $first).Sddl | Should -BeExactly $before[0] + (Get-Acl -LiteralPath $next).Sddl | Should -BeExactly $before[1] + } +} diff --git a/Tests/Audit.Tests.ps1 b/Tests/Audit.Tests.ps1 index f9fa2a8..e521adf 100644 --- a/Tests/Audit.Tests.ps1 +++ b/Tests/Audit.Tests.ps1 @@ -512,3 +512,104 @@ Describe 'InheritedFrom of audit entries' { $inherited[0].InheritedFrom | Should -Be $folder } } +Describe 'Audit changes with the Security privilege disabled' { + BeforeAll { + $holdsSecurityForOperations = Test-PrivilegeHeld -Name 'SeSecurityPrivilege' + } + + BeforeEach { + $savedEnablePrivileges = $privateData['EnablePrivileges'] + $securityWasEnabled = (Get-Privileges | Where-Object -Property Privilege -EQ -Value 'Security').PrivilegeState -eq 'Enabled' + } + + AfterEach { + $privateData['EnablePrivileges'] = $savedEnablePrivileges + if ($securityWasEnabled) { + $null = [ProcessPrivileges.ProcessExtensions]::EnablePrivilege( + [Diagnostics.Process]::GetCurrentProcess(), [ProcessPrivileges.Privilege]::Security + ) + } + else { + $null = [ProcessPrivileges.ProcessExtensions]::DisablePrivilege( + [Diagnostics.Process]::GetCurrentProcess(), [ProcessPrivileges.Privilege]::Security + ) + } + } + + It ' should use a held privilege or report a missing one and continue to the next path' -ForEach @( + @{ Command = 'Add-NTFSAudit'; ErrorId = 'AddAceError'; Parameters = @{ Account = 'S-1-1-0'; AccessRights = 'ReadData'; PassThru = $true } } + @{ Command = 'Remove-NTFSAudit'; ErrorId = 'RemoveAceError'; Parameters = @{ Account = 'S-1-1-0'; AccessRights = 'Delete'; PassThru = $true } } + @{ Command = 'Clear-NTFSAudit'; ErrorId = 'ClearAclError'; Parameters = @{ DisableInheritance = $true } } + @{ Command = 'Enable-NTFSAuditInheritance'; ErrorId = 'ModifySdError'; Parameters = @{ PassThru = $true; RemoveExplicitAuditRules = $true } } + @{ Command = 'Disable-NTFSAuditInheritance'; ErrorId = 'ModifySdError'; Parameters = @{ PassThru = $true; RemoveInheritedAuditRules = $true } } + ) { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'DisabledSecurity' + $missing = Join-Path -Path $sandbox -ChildPath ('MissingAudit-{0}' -f [guid]::NewGuid().ToString('N')) + Assert-TestSandboxPath -Sandbox $sandbox -Path $path, $missing + if ($holdsSecurityForOperations) { + $privateData['EnablePrivileges'] = $true + Add-NTFSAudit -Path $path -Account 'S-1-1-0' -AccessRights Delete -AuditFlags Success -AppliesTo ThisFolderOnly + $saclBefore = (Get-NTFSSecurityDescriptor -Path $path).SecurityDescriptor.GetSecurityDescriptorSddlForm('Audit') + } + $before = (Get-Acl -LiteralPath $path).Sddl + $privateData['EnablePrivileges'] = $false + $null = [ProcessPrivileges.ProcessExtensions]::DisablePrivilege( + [Diagnostics.Process]::GetCurrentProcess(), [ProcessPrivileges.Privilege]::Security + ) + + (Get-Privileges | Where-Object -Property Privilege -EQ -Value 'Security').PrivilegeState | Should -Not -Be 'Enabled' + + $result = @(& $Command -Path $path, $missing @Parameters -ErrorVariable auditErrors -ErrorAction SilentlyContinue) + + (Get-Acl -LiteralPath $path).Sddl | Should -BeExactly $before + if ($holdsSecurityForOperations) { + # AlphaFS temporarily enables a held Security privilege for SACL access, even with automatic privileges off. + (Get-Privileges | Where-Object -Property Privilege -EQ -Value 'Security').PrivilegeState | Should -Be 'Disabled' + $auditErrors | Should -HaveCount 1 + $auditErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadFileError,*' + $auditErrors[0].CategoryInfo.Category | Should -Be 'OpenError' + $auditErrors[0].TargetObject | Should -BeExactly $missing + $written = Get-NTFSSecurityDescriptor -Path $path + $rules = @($written.SecurityDescriptor.GetAuditRules( + $true, $false, [System.Security.Principal.SecurityIdentifier] + )) + switch ($Command) { + 'Add-NTFSAudit' { + $result | Should -Not -BeNullOrEmpty + $result | ForEach-Object { $_.FullName | Should -BeExactly $path } + @($rules | Where-Object { + $_.IdentityReference.Value -eq 'S-1-1-0' -and $_.FileSystemRights.HasFlag( + [System.Security.AccessControl.FileSystemRights]::ReadData + ) + }).Count | Should -BeGreaterThan 0 + } + 'Disable-NTFSAuditInheritance' { + $result | Should -HaveCount 1 + $result[0].AuditInheritanceEnabled | Should -BeFalse + $rules | Should -HaveCount 1 + $rules[0].FileSystemRights | Should -Be ([System.Security.AccessControl.FileSystemRights]::Delete) + } + 'Enable-NTFSAuditInheritance' { + $result | Should -HaveCount 1 + $result[0].AuditInheritanceEnabled | Should -BeTrue + $rules | Should -BeNullOrEmpty + } + default { + $result | Should -BeNullOrEmpty + $rules | Should -BeNullOrEmpty + } + } + $written.SecurityDescriptor.GetSecurityDescriptorSddlForm('Audit') | Should -Not -BeExactly $saclBefore + } + else { + $result | Should -BeNullOrEmpty + $auditErrors | Should -HaveCount 2 + $auditErrors[0].FullyQualifiedErrorId | Should -BeLike "$ErrorId,*" + $auditErrors[0].CategoryInfo.Category | Should -Be 'WriteError' + $auditErrors[0].TargetObject | Should -BeExactly $path + $auditErrors[1].FullyQualifiedErrorId | Should -BeLike 'ReadFileError,*' + $auditErrors[1].CategoryInfo.Category | Should -Be 'OpenError' + $auditErrors[1].TargetObject | Should -BeExactly $missing + } + } +} diff --git a/Tests/Inheritance.Tests.ps1 b/Tests/Inheritance.Tests.ps1 index a4e41fe..bf473cc 100644 --- a/Tests/Inheritance.Tests.ps1 +++ b/Tests/Inheritance.Tests.ps1 @@ -430,3 +430,81 @@ Describe 'Access inheritance cmdlets' { } } } +Describe 'Set-NTFSInheritance with an in-memory descriptor' { + It 'Should set access inheritance enabled= on a only when the descriptor is written' -ForEach @( + @{ Type = 'file'; Enable = $false } + @{ Type = 'file'; Enable = $true } + @{ Type = 'folder'; Enable = $false } + @{ Type = 'folder'; Enable = $true } + ) { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'DescriptorAccessState' -Directory:($Type -eq 'folder') + Assert-TestSandboxPath -Sandbox $sandbox -Path $path + Set-NTFSInheritance -Path $path -AccessInheritanceEnabled (-not $Enable) -ErrorAction Stop + Add-NTFSAccess -Path $path -Account 'S-1-1-0' -AccessRights ReadData -AppliesTo ThisFolderOnly + $before = (Get-Acl -LiteralPath $path).GetSecurityDescriptorSddlForm('Access') + $ownerBefore = (Get-Acl -LiteralPath $path).Owner + $sd = Get-NTFSSecurityDescriptor -Path $path + + $result = @(Set-NTFSInheritance -SecurityDescriptor $sd -AccessInheritanceEnabled $Enable -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].FullName | Should -BeExactly $path + $result[0].Name | Should -BeExactly ([IO.Path]::GetFileName($path)) + $result[0].AccessInheritanceEnabled | Should -Be $Enable + $sd.SecurityDescriptor.AreAccessRulesProtected | Should -Be (-not $Enable) + (Get-Acl -LiteralPath $path).GetSecurityDescriptorSddlForm('Access') | Should -BeExactly $before + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + (Get-Acl -LiteralPath $path).AreAccessRulesProtected | Should -Be (-not $Enable) + (Get-Acl -LiteralPath $path).Owner | Should -BeExactly $ownerBefore + @(Get-NTFSAccess -Path $path -ExcludeInherited | Where-Object { $_.Account.Sid -eq 'S-1-1-0' }) | + Should -HaveCount 1 + } + + It 'Should set audit inheritance enabled= on a without changing its DACL or owner' -Skip:(-not $canChangeAudit) -ForEach @( + @{ Type = 'file'; Enable = $false } + @{ Type = 'file'; Enable = $true } + @{ Type = 'folder'; Enable = $false } + @{ Type = 'folder'; Enable = $true } + ) { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'DescriptorAuditState' -Directory:($Type -eq 'folder') + Assert-TestSandboxPath -Sandbox $sandbox -Path $path + Set-NTFSInheritance -Path $path -AuditInheritanceEnabled (-not $Enable) -ErrorAction Stop + Add-NTFSAudit -Path $path -Account 'S-1-1-0' -AccessRights Delete -AuditFlags Success -AppliesTo ThisFolderOnly + $before = (Get-NTFSSecurityDescriptor -Path $path).SecurityDescriptor.GetSecurityDescriptorSddlForm('Audit') + $daclBefore = (Get-Acl -LiteralPath $path).GetSecurityDescriptorSddlForm('Access') + $ownerBefore = (Get-Acl -LiteralPath $path).Owner + $sd = Get-NTFSSecurityDescriptor -Path $path + + $result = @(Set-NTFSInheritance -SecurityDescriptor $sd -AuditInheritanceEnabled $Enable -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].AuditInheritanceEnabled | Should -Be $Enable + $sd.SecurityDescriptor.AreAuditRulesProtected | Should -Be (-not $Enable) + (Get-NTFSSecurityDescriptor -Path $path).SecurityDescriptor.GetSecurityDescriptorSddlForm('Audit') | + Should -BeExactly $before + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + (Get-NTFSInheritance -Path $path).AuditInheritanceEnabled | Should -Be $Enable + (Get-Acl -LiteralPath $path).GetSecurityDescriptorSddlForm('Access') | Should -BeExactly $daclBefore + (Get-Acl -LiteralPath $path).Owner | Should -BeExactly $ownerBefore + @(Get-NTFSAudit -Path $path -ExcludeInherited | Where-Object { $_.Account.Sid -eq 'S-1-1-0' }) | + Should -HaveCount 1 + } + + It 'Should keep audit inheritance unknown when requested enabled=<_> on an access-only descriptor' -ForEach @($false, $true) { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'DescriptorUnknownAudit' + $sd = New-Object -TypeName 'Security2.FileSystemSecurity2' -ArgumentList ( + (Get-Item2 -Path $path), [System.Security.AccessControl.AccessControlSections]::Access + ) + $before = (Get-Acl -LiteralPath $path).Sddl + + $result = @(Set-NTFSInheritance -SecurityDescriptor $sd -AuditInheritanceEnabled $_ -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].AuditInheritanceEnabled | Should -BeNullOrEmpty + $raw = New-Object -TypeName 'System.Security.AccessControl.RawSecurityDescriptor' -ArgumentList ( + $sd.SecurityDescriptor.GetSecurityDescriptorBinaryForm(), 0 + ) + $null -eq $raw.SystemAcl | Should -BeTrue + (Get-Acl -LiteralPath $path).Sddl | Should -BeExactly $before + } +} diff --git a/Tests/ItemCmdlets.Tests.ps1 b/Tests/ItemCmdlets.Tests.ps1 index d21eea2..324098d 100644 --- a/Tests/ItemCmdlets.Tests.ps1 +++ b/Tests/ItemCmdlets.Tests.ps1 @@ -777,3 +777,36 @@ Describe 'Get-DiskSpace' { Should -Throw -ErrorId 'ParameterArgumentValidationError,NTFSSecurity.GetDiskSpace' } } +Describe 'Get-ChildItem2 when recursive enumeration becomes denied' { + It 'Should name the failed recursion in verbose output and continue with the next path' { + $root = New-TestSandboxItem -Sandbox $sandbox -Name 'ChangingReadPermission' -Directory + $child = Join-Path -Path $root -ChildPath 'Child' + $first = Join-Path -Path $root -ChildPath 'First.txt' + $nested = Join-Path -Path $child -ChildPath 'Nested.txt' + $next = New-TestSandboxItem -Sandbox $sandbox -Name 'NextRecursivePath' -Directory + $nextFile = Join-Path -Path $next -ChildPath 'Next.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $root, $child, $first, $nested, $nextFile + New-Item -ItemType Directory -Path $child | Out-Null + Set-Content -LiteralPath $first -Value 'First' + Set-Content -LiteralPath $nested -Value 'Nested' + Set-Content -LiteralPath $nextFile -Value 'Next' + $ownerBefore = (Get-Acl -LiteralPath $root).Owner + + # The first file is emitted before the separate recursive directory enumeration opens the folder again. + $records = @(Get-ChildItem2 -Path $root, $next -File -Recurse -Verbose -ErrorVariable childErrors -ErrorAction SilentlyContinue 4>&1 | + ForEach-Object { + if ($_ -is [Alphaleonis.Win32.Filesystem.FileInfo] -and $_.FullName -eq $first) { + Add-TestDenyRule -Sandbox $sandbox -Path $root -Rights @{ 'S-1-1-0' = 'ReadData' } + } + $_ + }) + + $childErrors | Should -BeNullOrEmpty + $files = @($records | Where-Object { $_ -is [Alphaleonis.Win32.Filesystem.FileInfo] }) + @($files.FullName | Sort-Object) | Should -Be @(@($first, $nextFile) | Sort-Object) + $messages = @($records | Where-Object { $_ -is [System.Management.Automation.VerboseRecord] }) + $messages.Message | Should -Contain "Cannot access folder '$root' for recursive operation" + (Get-Acl -LiteralPath $root).Owner | Should -BeExactly $ownerBefore + Get-Content -LiteralPath $nested | Should -BeExactly 'Nested' + } +} diff --git a/Tests/ObjectApis.Tests.ps1 b/Tests/ObjectApis.Tests.ps1 new file mode 100644 index 0000000..d5ea1dc --- /dev/null +++ b/Tests/ObjectApis.Tests.ps1 @@ -0,0 +1,263 @@ +<# + Tests the public object APIs used with cmdlet output, without changing an item's security descriptor. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.' +)] +param () + +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 'ObjectApis' + $objectPath = Join-Path -Path $sandbox -ChildPath 'Rule.txt' + $identity = [Security2.IdentityReference2] 'S-1-1-0' + $sid = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList 'S-1-1-0' +} + +AfterAll { + Remove-TestSandbox -Sandbox $sandbox + Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue +} + +Describe 'Rule constructors with a path' { + It 'Should preserve the supplied path and name of an rule' -ForEach @( + @{ Kind = 'access' } + @{ Kind = 'audit' } + ) { + if ($Kind -eq 'access') { + $raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAccessRule' -ArgumentList ( + $sid, [System.Security.AccessControl.FileSystemRights]::ReadData, + [System.Security.AccessControl.AccessControlType]::Allow + ) + $rule = New-Object -TypeName 'Security2.FileSystemAccessRule2' -ArgumentList $raw, $objectPath + } + else { + $raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList ( + $sid, [System.Security.AccessControl.FileSystemRights]::ReadData, + [System.Security.AccessControl.AuditFlags]::Success + ) + $rule = New-Object -TypeName 'Security2.FileSystemAuditRule2' -ArgumentList $raw, $objectPath + } + + $rule.FullName | Should -BeExactly $objectPath + $rule.Name | Should -BeExactly 'Rule.txt' + $rule.InheritanceEnabled = $true + $rule.InheritedFrom = $sandbox + $rule.InheritanceEnabled | Should -BeTrue + $rule.InheritedFrom | Should -BeExactly $sandbox + $rule.GetHashCode() | Should -Be $raw.GetHashCode() + } +} + +Describe 'Simplified audit entries' { + It 'Should reduce to ' -ForEach @( + @{ Rights = 'None'; Expected = 'None' } + @{ Rights = 'ReadData'; Expected = 'Read' } + @{ Rights = 'Read'; Expected = 'Read' } + @{ Rights = 'CreateFiles'; Expected = 'Write' } + @{ Rights = 'AppendData'; Expected = 'Write' } + @{ Rights = 'ReadExtendedAttributes'; Expected = 'Read' } + @{ Rights = 'WriteExtendedAttributes'; Expected = 'Write' } + @{ Rights = 'ExecuteFile'; Expected = 'Read' } + @{ Rights = 'DeleteSubdirectoriesAndFiles'; Expected = 'Delete' } + @{ Rights = 'ReadAttributes'; Expected = 'Read' } + @{ Rights = 'WriteAttributes'; Expected = 'Write' } + @{ Rights = 'Delete'; Expected = 'Delete' } + @{ Rights = 'ReadPermissions'; Expected = 'Read' } + @{ Rights = 'ChangePermissions'; Expected = 'Write' } + @{ Rights = 'TakeOwnership'; Expected = 'Write' } + @{ Rights = 'Synchronize'; Expected = 'Read' } + @{ Rights = 'FullControl'; Expected = 'Read, Write, Delete' } + @{ Rights = 'GenericRead'; Expected = 'Read' } + @{ Rights = 'GenericWrite'; Expected = 'Write' } + @{ Rights = 'GenericExecute'; Expected = 'Read' } + @{ Rights = 'GenericAll'; Expected = 'Read, Write, Delete' } + ) { + $rule = New-Object -TypeName 'Security2.SimpleFileSystemAuditRule' -ArgumentList ( + $objectPath, $identity, [Security2.FileSystemRights2] $Rights + ) + + $rule.AccessRights | Should -Be ([Security2.SimpleFileSystemAccessRights] $Expected) + $rule.FullName | Should -BeExactly $objectPath + $rule.Name | Should -BeExactly 'Rule.txt' + $rule.Identity.Sid | Should -BeExactly 'S-1-1-0' + } + + It 'Should compare audit entries reflexively and symmetrically, never as access entries' { + $first = New-Object -TypeName 'Security2.SimpleFileSystemAuditRule' -ArgumentList ( + $objectPath, $identity, [Security2.FileSystemRights2]::Read + ) + $second = New-Object -TypeName 'Security2.SimpleFileSystemAuditRule' -ArgumentList ( + $objectPath, $identity, [Security2.FileSystemRights2]::Read + ) + $access = New-Object -TypeName 'Security2.SimpleFileSystemAccessRule' -ArgumentList ( + $objectPath, $identity, [Security2.FileSystemRights2]::Read, + [System.Security.AccessControl.AccessControlType]::Allow + ) + + $first.Equals($first) | Should -BeTrue + $first.Equals($second) | Should -BeTrue + $second.Equals($first) | Should -BeTrue + $first.GetHashCode() | Should -Be $second.GetHashCode() + $first.Equals($access) | Should -BeFalse + $access.Equals($first) | Should -BeFalse + $first.Equals($null) | Should -BeFalse + $first.Equals('Read') | Should -BeFalse + $second.AccessControlType = 'Deny' + $first.Equals($second) | Should -BeFalse + } + + It 'Should preserve the path, account and ReadData when converting an audit entry' { + $raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList ( + $sid, [System.Security.AccessControl.FileSystemRights]::ReadData, + [System.Security.AccessControl.AuditFlags]::Success + ) + $wrapped = New-Object -TypeName 'Security2.FileSystemAuditRule2' -ArgumentList $raw, $objectPath + + $simple = $wrapped.ToSimpleFileSystemAuditRule2() + + $simple.FullName | Should -BeExactly $objectPath + $simple.Identity.Sid | Should -BeExactly 'S-1-1-0' + $simple.AccessRights | Should -Be ([Security2.SimpleFileSystemAccessRights]::Read) + $wrapped.ToString() | Should -BeExactly $raw.ToString() + } +} + +Describe 'Identity comparisons and conversions' { + It 'Should compare with the identity by SID or resolved name' -ForEach @( + @{ Value = 'self'; Expected = $true } + @{ Value = 'same SID'; Expected = $true } + @{ Value = 'different SID'; Expected = $false } + @{ Value = 'SecurityIdentifier'; Expected = $true } + @{ Value = 'NTAccount'; Expected = $true } + @{ Value = 'SID string'; Expected = $true } + @{ Value = 'account name'; Expected = $true } + @{ Value = 'different string'; Expected = $false } + @{ Value = 'null'; Expected = $false } + @{ Value = 'other type'; Expected = $false } + ) { + $other = switch ($Value) { + 'self' { $identity } + 'same SID' { [Security2.IdentityReference2] 'S-1-1-0' } + 'different SID' { [Security2.IdentityReference2] 'S-1-5-32-546' } + 'SecurityIdentifier' { $sid } + 'NTAccount' { $sid.Translate([System.Security.Principal.NTAccount]) } + 'SID string' { 'S-1-1-0' } + 'account name' { $identity.AccountName.ToUpperInvariant() } + 'different string' { 'NTFSSecurity-not-an-account' } + 'null' { $null } + 'other type' { 42 } + } + + $identity.Equals($other) | Should -Be $Expected + } + + It 'Should compare null operands and distinct instances through both operators' { + $same = [Security2.IdentityReference2] 'S-1-1-0' + $different = [Security2.IdentityReference2] 'S-1-5-32-546' + + [Security2.IdentityReference2]::op_Equality($null, $null) | Should -BeTrue + [Security2.IdentityReference2]::op_Equality($identity, $null) | Should -BeFalse + [Security2.IdentityReference2]::op_Equality($null, $identity) | Should -BeFalse + [Security2.IdentityReference2]::op_Equality($identity, $same) | Should -BeTrue + [Security2.IdentityReference2]::op_Inequality($identity, $identity) | Should -BeFalse + [Security2.IdentityReference2]::op_Inequality($identity, $null) | Should -BeTrue + [Security2.IdentityReference2]::op_Inequality($null, $identity) | Should -BeTrue + [Security2.IdentityReference2]::op_Inequality($identity, $different) | Should -BeTrue + [Security2.IdentityReference2]::op_Inequality($identity, $same) | Should -BeFalse + $identity.GetHashCode() | Should -Be $same.GetHashCode() + } + + It 'Should round-trip native identities and the binary SID without changing the account' { + $account = $sid.Translate([System.Security.Principal.NTAccount]) + $fromSid = [Security2.IdentityReference2]::op_Explicit($sid) + $fromAccount = [Security2.IdentityReference2]::op_Explicit($account) + + $fromSid.Sid | Should -BeExactly 'S-1-1-0' + $fromAccount.Sid | Should -BeExactly $fromSid.Sid + ([System.Security.Principal.SecurityIdentifier] $fromSid).Value | Should -BeExactly 'S-1-1-0' + ([System.Security.Principal.NTAccount] $fromAccount).Value | Should -BeExactly $account.Value + $binarySid = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList ( + $fromSid.GetBinaryForm(), 0 + ) + $binarySid.Value | Should -BeExactly 'S-1-1-0' + } +} + +Describe 'Unrepresentable inheritance flags' { + It 'Should reject propagation flags without inheritance instead of inventing an AppliesTo value' { + $failure = $null + try { + $null = [Security2.FileSystemSecurity2]::ConvertToApplyTo('None', 'InheritOnly') + } + catch { + $failure = $_.Exception.GetBaseException() + } + + $failure | Should -BeOfType [Security2.RightsConverionException] + $failure.Message | Should -BeExactly 'The combination of InheritanceFlags and PropagationFlags could not be translated' + } +} +Describe 'Privilege output comparisons and formatting' { + It 'Should compare boxed and typed privilege values consistently without accepting an attributes enum' { + $values = @(Get-Privileges) + $values.Count | Should -BeGreaterThan 0 + $first = $values[0].PSObject.BaseObject + $copy = $values[0].PSObject.BaseObject + # PowerShell prefers the typed overload; reflection selects the public boxed-object contract explicitly. + $equalsObject = [ProcessPrivileges.PrivilegeAndAttributes].GetMethod('Equals', [type[]] @([object])) + + $equalsObject.Invoke($first, [object[]] @($copy)) | Should -BeTrue + $first.Equals($copy) | Should -BeTrue + [ProcessPrivileges.PrivilegeAndAttributes]::op_Equality($first, $copy) | Should -BeTrue + [ProcessPrivileges.PrivilegeAndAttributes]::op_Inequality($first, $copy) | Should -BeFalse + $first.GetHashCode() | Should -Be $copy.GetHashCode() + $equalsObject.Invoke($first, [object[]] @($null)) | Should -BeFalse + $equalsObject.Invoke($first, [object[]] @('Backup')) | Should -BeFalse + $equalsObject.Invoke($first, [object[]] @([ProcessPrivileges.PrivilegeAttributes]::Disabled)) | Should -BeFalse + } + + It 'Should format the collection with one aligned privilege and attributes row per value' { + $control = New-Object -TypeName 'Security2.PrivilegeControl' + $values = $control.GetPrivileges() + $expectedWidth = ($values | ForEach-Object { $_.Privilege.ToString().Length } | Measure-Object -Maximum).Maximum + + $text = $values.ToString() + + $rows = @($text.TrimEnd("`r", "`n") -split '\r?\n') + $rows | Should -HaveCount $values.Count + for ($index = 0; $index -lt $values.Count; $index++) { + $value = $values[$index] + $rows[$index] | Should -BeExactly ('{0} => {1}' -f $value.Privilege.ToString().PadRight($expectedWidth), + $value.PrivilegeAttributes) + } + } +} + +Describe 'Legacy effective-permission output objects' { + It 'Should retain a mask and report the supplied path and identity without a native access check' -ForEach @( + @{ Mask = 0; ObjectName = 'Effective.txt' } + @{ Mask = 1; ObjectName = 'Effective.txt' } + @{ Mask = 3; ObjectName = $null } + ) { + $path = if ($ObjectName) { Join-Path -Path $sandbox -ChildPath $ObjectName } else { $null } + $entry = New-Object -TypeName 'Security2.FileSystemEffectivePermissionEntry' -ArgumentList ( + $identity, [uint32] $Mask, $path + ) + + $entry.Account.Sid | Should -BeExactly 'S-1-1-0' + $entry.AccessMask | Should -Be $Mask + [int] $entry.AccessRights | Should -Be $Mask + $entry.FullName | Should -BeExactly ([string] $path) + $entry.Name | Should -BeExactly $ObjectName + $entry.AccessAsString | Should -Not -BeNullOrEmpty + if ($Mask -eq 0) { + $entry.AccessAsString | Should -Be @('None') + } + else { + $entry.AccessAsString | Should -Not -Contain 'None' + } + } +}