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' + } + } +}