From 7c3de81f2f04f5f1313d76d1cb23462e7e04b6a2 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 20 Oct 2026 13:16:04 +0000 Subject: [PATCH] test(audit): close budget gaps for Add/Remove/Clear/Get-NTFSAudit and Get-NTFSOrphanedAudit Closes the eight remaining 'budget' parameter-set gaps that Quality-Gate-Unknowns-2026-10-10-ParameterSets.csv lists for the audit cmdlets, in Tests\ParameterSets.Audit.Tests.ps1 only: - Add-NTFSAudit PathComplex: binds -Account (several accounts) and -AuditFlags from a single piped object's property names in one call. - Remove-NTFSAudit PathComplex: removes for several accounts named in one call, and leaves the empty Audit SDDL and the DACL unchanged for an item that has no SACL. - Remove-NTFSAudit SDSimple and SDComplex: proves the item on disk keeps its audit section until Set-NTFSSecurityDescriptor writes the change, and that -PassThru returns FileSystemAuditRule2 objects for both sets. - Clear-NTFSAudit Path: asserts the cleared state of the succeeding path that follows a missing one in the same pipeline. - Get-NTFSAudit SD: asserts the output's type, account, rights, flags, and inherited state directly, independent of the Path set's output. - Get-NTFSOrphanedAudit Path: adds the OpenError category of the ReadSecurityError without the Security privilege (PermissionDenied needs a remote/lab target and is not in scope), and -ExcludeExplicit. - Get-NTFSOrphanedAudit SD: adds -Account and -ExcludeInherited/ -ExcludeExplicit filters against a piped security descriptor. Every new test reads state back with an independent oracle (Get-Acl -Audit or the .NET SACL-only accessor), and the Set-NTFSSecurityDescriptor cases confirm the SD cmdlets change the item only in memory. The owner retry after access denied (Limit) and the open maintainer item on exceptions from the SD change itself (MaintainerDecision) are left out, as the worklist directs. 9 new It blocks (10 executed cases; one is split SDSimple/SDComplex by -ForEach) were verified test-first: a mutated copy with one flipped expectation per new It failed only those 10 cases in the configurations where they run, and nothing else; three repeated runs of the real file in all four configurations (Windows PowerShell 5.1 and PowerShell 7, elevated and basic user) gave identical, all-green results; and Invoke-ScriptAnalyzer reports nothing, matching the base commit's file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/ParameterSets.Audit.Tests.ps1 | 190 ++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) diff --git a/Tests/ParameterSets.Audit.Tests.ps1 b/Tests/ParameterSets.Audit.Tests.ps1 index c715f1e..a22289a 100644 --- a/Tests/ParameterSets.Audit.Tests.ps1 +++ b/Tests/ParameterSets.Audit.Tests.ps1 @@ -157,6 +157,24 @@ Describe 'Add-NTFSAudit' { } } + Context 'Binding -Account and -AuditFlags from the pipeline by property name' { + # judged-audit.csv (Add-NTFSAudit PathComplex): no existing call binds -AuditFlags by property name, and + # none adds for several accounts in one call; this piped object binds both at once. + It 'Should add one audit entry for each account that the piped object names, with the AuditFlags it also names' -Skip:(-not $canReadAudit) { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'AddPipedAuditSeveralAccounts' -Directory + + [pscustomobject]@{ FullName = $folder; Account = @($probe, $keeper); AccessRights = 'ReadData'; AuditFlags = 'Failure'; InheritanceFlags = 'None'; PropagationFlags = 'None' } | Add-NTFSAudit -ErrorAction Stop + + $acl = Get-Acl -LiteralPath $folder -Audit + foreach ($account in $probe, $keeper) { + $rule = Get-TestAccountAuditRule -Acl $acl -Account $account + $rule | Should -HaveCount 1 + $rule[0].AuditFlags | Should -Be ([System.Security.AccessControl.AuditFlags]::Failure) + $rule[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::ReadData) | Should -BeTrue + } + } + } + Context 'Binding -SecurityDescriptor from the pipeline by property name' { It 'Should add the audit entry for the piped object that has an audit section, write a ReadSecurityError for the one that does not, and leave the item on disk unchanged until Set-NTFSSecurityDescriptor writes it' -ForEach @( @{ Set = 'SDSimple'; ExtraProperties = @{ AppliesTo = 'ThisFolderOnly' } } @@ -478,6 +496,44 @@ Describe 'Remove-NTFSAudit' { } } + Context 'Removing for several accounts in one call' { + # judged-audit.csv (Remove-NTFSAudit PathComplex): no existing call removes for several accounts in one call. + It 'Should remove the audit entry of each account that -Account names in one call, and leave another account alone' -Skip:(-not $canReadAudit) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveSeveralAccounts' + $bystander = 'S-1-5-11' + foreach ($account in $probe, $keeper, $bystander) { + Add-NTFSAudit -Path $file -Account $account -AccessRights ReadData -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + } + $daclBefore = Get-TestDaclSddl -Path $file + + $result = @(Remove-NTFSAudit -Path $file -Account $probe, $keeper -AccessRights ReadData -InheritanceFlags None -PropagationFlags None -PassThru -ErrorAction Stop) + + $acl = Get-Acl -LiteralPath $file -Audit + (Get-TestAccountAuditRule -Acl $acl -Account $probe) | Should -BeNullOrEmpty + (Get-TestAccountAuditRule -Acl $acl -Account $keeper) | Should -BeNullOrEmpty + (Get-TestAccountAuditRule -Acl $acl -Account $bystander) | Should -HaveCount 1 + $result | Should -HaveCount 1 + $result[0].Account.Sid | Should -Be $bystander + Get-TestDaclSddl -Path $file | Should -BeExactly $daclBefore + } + } + + Context 'When the item has no SACL' { + # judged-audit.csv (Remove-NTFSAudit PathComplex): Audit.Tests.ps1:406 asserts no error only; this adds the + # empty Audit SDDL and the unchanged DACL after the call. + It 'Should leave the empty Audit SDDL and the DACL unchanged' -Skip:(-not $canReadAudit) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveNoSaclComplex' + Get-TestAuditSddl -Path $file | Should -BeNullOrEmpty + $daclBefore = Get-TestDaclSddl -Path $file + + Remove-NTFSAudit -Path $file -Account $probe -AccessRights ReadData -InheritanceFlags None -PropagationFlags None -ErrorVariable removeErrors -ErrorAction SilentlyContinue + + $removeErrors | Should -BeNullOrEmpty + Get-TestAuditSddl -Path $file | Should -BeNullOrEmpty + Get-TestDaclSddl -Path $file | Should -BeExactly $daclBefore + } + } + Context 'Direct call of -Path with -AppliesTo' { # Remove-NTFSAudit PathSimple (judged-audit.csv): every existing call binds it only through a splat # (PermissionScopes.Tests.ps1); this one names the set's parameters directly. @@ -512,6 +568,38 @@ Describe 'Remove-NTFSAudit' { (Get-TestAccountAuditRule -Acl $sd.SecurityDescriptor -Account $keeper) | Should -HaveCount 1 } } + + Context 'Leaving the item on disk untouched until Set-NTFSSecurityDescriptor writes the change' { + # judged-audit.csv (Remove-NTFSAudit SDSimple and SDComplex): the pipeline test above (binding + # -SecurityDescriptor by property name) proves only that the in-memory descriptor lost the account's rule; + # this proves the item on disk keeps its audit section until Set-NTFSSecurityDescriptor writes it, and that + # -PassThru returns audit rule objects for the SDComplex set too. + It 'Should remove the entry from the descriptor only in memory, until Set-NTFSSecurityDescriptor writes it, and return audit rule objects with -PassThru' -ForEach @( + @{ Set = 'SDSimple'; ExtraProperties = @{ AppliesTo = 'ThisFolderOnly' } } + @{ Set = 'SDComplex'; ExtraProperties = @{ InheritanceFlags = 'None'; PropagationFlags = 'None' } } + ) -Skip:(-not $canReadAudit) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name "RemoveSDUntouched$Set" + Add-NTFSAudit -Path $file @entry @ExtraProperties -ErrorAction Stop + Add-NTFSAudit -Path $file -Account $keeper -AccessRights Delete @ExtraProperties -ErrorAction Stop + $auditBefore = Get-TestAuditSddl -Path $file + $daclBefore = Get-TestDaclSddl -Path $file + $sd = Get-NTFSSecurityDescriptor -Path $file -ErrorAction Stop + + $result = @(Remove-NTFSAudit -SecurityDescriptor $sd @entry @ExtraProperties -PassThru -ErrorAction Stop) + + $result | Should -Not -BeNullOrEmpty + $result | ForEach-Object -Process { $_ | Should -BeOfType [Security2.FileSystemAuditRule2] } + (Get-TestAccountAuditRule -Acl $sd.SecurityDescriptor -Account $probe) | Should -BeNullOrEmpty + Get-TestAuditSddl -Path $file | Should -BeExactly $auditBefore + + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + + (Get-TestAccountAuditRule -Acl (Get-TestAuditAcl -Path $file) -Account $probe) | Should -BeNullOrEmpty + (Get-TestAccountAuditRule -Acl (Get-TestAuditAcl -Path $file) -Account $keeper) | Should -HaveCount 1 + Get-TestAuditSddl -Path $file | Should -Not -BeExactly $auditBefore + Get-TestDaclSddl -Path $file | Should -BeExactly $daclBefore + } + } } Describe 'Clear-NTFSAudit' { @@ -532,6 +620,25 @@ Describe 'Clear-NTFSAudit' { Get-TestDaclSddl -Path $first | Should -BeExactly $daclBefore[0] Get-TestDaclSddl -Path $second | Should -BeExactly $daclBefore[1] } + + # judged-audit.csv (Clear-NTFSAudit Path): no existing test pipes a failing path and asserts the result of + # the succeeding path that follows it. + It 'Should clear the audit entries of the path that follows a missing one, and write only a ReadFileError for the missing one' -Skip:(-not $canReadAudit) { + $missing = Join-Path -Path $sandbox -ChildPath ('ClearPipeMissing-{0}' -f [guid]::NewGuid().ToString('N')) + Assert-TestSandboxPath -Sandbox $sandbox -Path $missing + $good = New-TestSandboxItem -Sandbox $sandbox -Name 'ClearPipeGoodAfterMissing' + Add-NTFSAudit -Path $good @entry -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + $daclBefore = Get-TestDaclSddl -Path $good + + $missing, $good | Clear-NTFSAudit -ErrorVariable clearErrors -ErrorAction SilentlyContinue + + $clearErrors | Should -HaveCount 1 + $clearErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadFileError,*' + $clearErrors[0].CategoryInfo.Category | Should -Be 'OpenError' + $clearErrors[0].TargetObject | Should -Be $missing + @(Get-NTFSAudit -Path $good -ExcludeInherited) | Should -BeNullOrEmpty + Get-TestDaclSddl -Path $good | Should -BeExactly $daclBefore + } } Context 'With -DisableInheritance' { @@ -663,6 +770,27 @@ Describe 'Get-NTFSAudit' { ($result | ForEach-Object -Process { '{0}|{1}|{2}' -f $_.Account.Sid, $_.AccessRights, $_.AuditFlags }) -join ';' | Should -Be ($expected -join ';') } } + + Context 'Output of a security descriptor, independent of the Path set' { + # judged-audit.csv (Get-NTFSAudit SD): the pipeline test above compares the SD set's output with the Path + # set's output; this asserts concrete values instead. + It 'Should return the entries of a security descriptor with the expected type, account, rights, flags, and inherited state' -Skip:(-not $canReadAudit) { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'GetAuditSDValues' -Directory + Add-NTFSAudit -Path $folder -Account $probe -AccessRights ReadData -AuditFlags Failure -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + $sd = Get-NTFSSecurityDescriptor -Path $folder -ErrorAction Stop + + $result = @(Get-NTFSAudit -SecurityDescriptor $sd -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0] | Should -BeOfType [Security2.FileSystemAuditRule2] + $result[0].FullName | Should -Be $folder + $result[0].Account.Sid | Should -Be $probe + $result[0].AccessRights.HasFlag([Security2.FileSystemRights2]::ReadData) | Should -BeTrue + $result[0].AuditFlags | Should -Be ([System.Security.AccessControl.AuditFlags]::Failure) + $result[0].IsInherited | Should -BeFalse + $result[0].InheritanceEnabled | Should -BeTrue + } + } } Describe 'Get-NTFSOrphanedAudit' { @@ -687,6 +815,43 @@ Describe 'Get-NTFSOrphanedAudit' { } } + Context 'Without the Security privilege' { + # judged-audit.csv (Get-NTFSOrphanedAudit Path): Audit.Tests.ps1:304 asserts the ErrorId only; this adds + # the category. Without the privilege, .NET throws PrivilegeNotHeldException, not UnauthorizedAccessException, + # so the category is OpenError, like Get-NTFSAudit (Audit.Tests.ps1:48); PermissionDenied is a Limit (needs a + # lab or a remote server). + It 'Should write a ReadSecurityError with category OpenError and the path as target' -Skip:$canReadAudit { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'OrphanedNoPrivilege' + + $result = @(Get-NTFSOrphanedAudit -Path $file -ErrorVariable orphanedErrors -ErrorAction SilentlyContinue) + + $result | Should -BeNullOrEmpty + $orphanedErrors | Should -HaveCount 1 + $orphanedErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadSecurityError,*' + $orphanedErrors[0].CategoryInfo.Category | Should -Be 'OpenError' + $orphanedErrors[0].TargetObject | Should -Be $file + } + } + + Context 'Filtering a path with -ExcludeExplicit' { + It 'Should return only the inherited orphaned entry with -ExcludeExplicit' -Skip:(-not $canReadAudit) { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'OrphanedExcludeExplicitFolder' -Directory + Add-NTFSAudit -Path $folder -Account 'S-1-5-21-1-2-3-9101' -AccessRights ReadData -AppliesTo ThisFolderAndFiles -ErrorAction Stop + $child = Join-Path -Path $folder -ChildPath 'Child.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $child + Set-Content -LiteralPath $child -Value 'Child' + Add-NTFSAudit -Path $child -Account 'S-1-5-21-1-2-3-9102' -AccessRights Delete -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + + $all = @(Get-NTFSOrphanedAudit -Path $child -ErrorAction Stop) + $result = @(Get-NTFSOrphanedAudit -Path $child -ExcludeExplicit -ErrorAction Stop) + + $all | Should -HaveCount 2 + $result | Should -HaveCount 1 + $result[0].Account.Sid | Should -Be 'S-1-5-21-1-2-3-9101' + $result[0].IsInherited | Should -BeTrue + } + } + Context 'Binding -SecurityDescriptor from the pipeline' { It 'Should return the orphaned entries of the piped descriptor that has an audit section and continue past one that does not' -Skip:(-not $canReadAudit) { $badFile = New-TestSandboxItem -Sandbox $sandbox -Name 'OrphanedSDBad' @@ -723,4 +888,29 @@ Describe 'Get-NTFSOrphanedAudit' { $result[0].FullName | Should -Be $file } } + + Context '-Account and -Exclude filters with a descriptor' { + # judged-audit.csv (Get-NTFSOrphanedAudit SD): no existing test passes -Account or -ExcludeInherited/ + # -ExcludeExplicit together with a descriptor. + It 'Should filter the orphaned entries of a piped descriptor by -Account, -ExcludeExplicit, and -ExcludeInherited' -Skip:(-not $canReadAudit) { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'OrphanedSDFilterFolder' -Directory + Add-NTFSAudit -Path $folder -Account 'S-1-5-21-1-2-3-9201' -AccessRights ReadData -AppliesTo ThisFolderAndFiles -ErrorAction Stop + $child = Join-Path -Path $folder -ChildPath 'Child.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $child + Set-Content -LiteralPath $child -Value 'Child' + Add-NTFSAudit -Path $child -Account 'S-1-5-21-1-2-3-9202' -AccessRights Delete -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + $sd = Get-NTFSSecurityDescriptor -Path $child -ErrorAction Stop + + $inheritedOnly = @(Get-NTFSOrphanedAudit -SecurityDescriptor $sd -ExcludeExplicit -ErrorAction Stop) + $explicitOnly = @(Get-NTFSOrphanedAudit -SecurityDescriptor $sd -ExcludeInherited -ErrorAction Stop) + $byAccount = @(Get-NTFSOrphanedAudit -SecurityDescriptor $sd -Account 'S-1-5-21-1-2-3-9202' -ErrorAction Stop) + + $inheritedOnly | Should -HaveCount 1 + $inheritedOnly[0].Account.Sid | Should -Be 'S-1-5-21-1-2-3-9201' + $explicitOnly | Should -HaveCount 1 + $explicitOnly[0].Account.Sid | Should -Be 'S-1-5-21-1-2-3-9202' + $byAccount | Should -HaveCount 1 + $byAccount[0].Account.Sid | Should -Be 'S-1-5-21-1-2-3-9202' + } + } }