From 3fa5adeb0c6dff5112366242a710a4313c80fddd Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Sat, 10 Oct 2026 20:31:56 +0000 Subject: [PATCH] test(audit): assert the parameter sets that the judged matrix found untested Add Tests\ParameterSets.Audit.Tests.ps1, closing the TestNeeded gaps that judged-audit.csv/judged-audit-notes.md recorded for Add-NTFSAudit, Remove-NTFSAudit, Clear-NTFSAudit, Get-NTFSAudit, and Get-NTFSOrphanedAudit: - Pipeline binding by value and by property name for every parameter set of all five cmdlets, including the four sets the matrix flagged as bound only by splat or pipe calls (Add-NTFSAudit SDSimple, Remove-NTFSAudit PathSimple and SDSimple, Get-NTFSOrphanedAudit SD), each of which also gets a direct, named call. - Continuation past a failing path or a descriptor without an audit section, asserting the ErrorId, category, and target of the error and the state or output of the item that follows. - State assertions beyond -PassThru: SACL/DACL SDDL before and after, the in-memory descriptor versus the item on disk until Set-NTFSSecurityDescriptor writes it, and the protected/unprotected SACL after Clear-NTFSAudit -DisableInheritance. - Narrowing of an audit entry's rights when Remove-NTFSAudit runs without -RemoveSpecific, and the AmbiguousParameterSet error when -AppliesTo and -InheritanceFlags are combined. No production code, existing test file, or helper module changed. Fixture DACLs use .NET SetAccessControl through the existing TestHelpers.psm1 helpers; no test pins the open item of the -SecurityDescriptor sets of Add and Remove having no per-item handler around the change itself. Verified in all four configurations (elevated/basic user x Windows PowerShell 5.1/PowerShell 7): 0 failures. Mutation-tested with 9 one-statement changes to the production C# in a separate, detached worktree; 8 were caught by the new tests and one (the -AppliesTo-to-flags conversion for the Simple sets, masked on a file target) was not, which the pre-existing PermissionScopes.Tests.ps1 already catches on a folder target. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/ParameterSets.Audit.Tests.ps1 | 512 ++++++++++++++++++++++++++++ 1 file changed, 512 insertions(+) create mode 100644 Tests/ParameterSets.Audit.Tests.ps1 diff --git a/Tests/ParameterSets.Audit.Tests.ps1 b/Tests/ParameterSets.Audit.Tests.ps1 new file mode 100644 index 0000000..4677afe --- /dev/null +++ b/Tests/ParameterSets.Audit.Tests.ps1 @@ -0,0 +1,512 @@ +<# + Closes the parameter-set gaps that the judged test matrix (judged-audit.csv / judged-audit-notes.md) found for + the audit cmdlets: pipeline binding by value and by property name, continuation past a failing path or + descriptor, and state or output assertions beyond -PassThru, with the module built in NTFSSecurity\bin\Release + on files in a sandbox folder. Reading and changing audit entries needs the Security privilege; tests that need + it skip without it and run in CI, whose runners are elevated, like Audit.Tests.ps1. The -SecurityDescriptor sets + of Add-NTFSAudit and Remove-NTFSAudit have no per-item handler around the change itself (an open maintainer + item), so no test here pins what happens when that change throws for one of several descriptors; the only + failure the -SecurityDescriptor scenarios below exercise is the already-handled one of a descriptor that was + read without its audit section. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.' +)] +param () + +BeforeDiscovery { + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force + $canReadAudit = Test-PrivilegeHeld -Name 'SeSecurityPrivilege' +} + +BeforeAll { + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force + $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\NTFSSecurity\bin\Release\NTFSSecurity.psd1' + Import-Module -Name $modulePath -Force -ErrorAction Stop + $sandbox = New-TestSandbox -Name 'ParameterSetsAudit' + Push-Location -LiteralPath $sandbox + + $sidType = [System.Security.Principal.SecurityIdentifier] + $probe = 'S-1-1-0' + $keeper = 'S-1-5-32-545' + $entry = @{ Account = $probe; AccessRights = 'ReadData' } + + function Get-TestAuditSddl { + param ([Parameter(Mandatory)] [string] $Path) + (Get-Acl -LiteralPath $Path -Audit).GetSecurityDescriptorSddlForm('Audit') + } + + function Get-TestDaclSddl { + param ([Parameter(Mandatory)] [string] $Path) + (Get-Acl -LiteralPath $Path).Sddl + } + + function Get-TestAccessOnlyDescriptor { + # A descriptor read with the Access section only, like one that Get-NTFSSecurityDescriptor returns without + # the Security privilege: the audit cmdlets have nothing to read or change and report it the same way. + param ([Parameter(Mandatory)] [string] $Path) + New-Object -TypeName 'Security2.FileSystemSecurity2' -ArgumentList ( + (Get-Item2 -Path $Path), [System.Security.AccessControl.AccessControlSections]::Access + ) + } + + function Get-TestAccountAuditRule { + param ( + [Parameter(Mandatory)] [System.Security.AccessControl.FileSystemSecurity] $Acl, + [Parameter(Mandatory)] [string] $Account + ) + @($Acl.GetAuditRules($true, $false, $sidType) | Where-Object -FilterScript { $_.IdentityReference.Value -eq $Account }) + } +} + +AfterAll { + Pop-Location + Remove-TestSandbox -Sandbox $sandbox + Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue +} + +Describe 'Audit cmdlets with a security descriptor that has no audit section' { + # TestAuditSection (#109) handles this the same way for every cmdlet that takes -SecurityDescriptor: Audit.Tests.ps1 + # line 425 already asserts the ErrorId for Add-NTFSAudit, Remove-NTFSAudit, and Clear-NTFSAudit; this data set adds + # the category and the target, which must be the descriptor itself, not its path, and also covers Get-NTFSAudit and + # Get-NTFSOrphanedAudit and the Simple parameter sets of Add and Remove. None of this needs the Security privilege: + # the descriptor already carries no audit section before the cmdlet runs. + It ' should write a ReadSecurityError with category InvalidData and the descriptor as target for the set, and change nothing' -ForEach @( + @{ Command = 'Add-NTFSAudit'; Set = 'SDSimple'; Parameters = @{ Account = 'S-1-1-0'; AccessRights = 'ReadData'; AppliesTo = 'ThisFolderOnly'; PassThru = $true } } + @{ Command = 'Add-NTFSAudit'; Set = 'SDComplex'; Parameters = @{ Account = 'S-1-1-0'; AccessRights = 'ReadData'; PassThru = $true } } + @{ Command = 'Remove-NTFSAudit'; Set = 'SDSimple'; Parameters = @{ Account = 'S-1-1-0'; AccessRights = 'ReadData'; AppliesTo = 'ThisFolderOnly'; PassThru = $true } } + @{ Command = 'Remove-NTFSAudit'; Set = 'SDComplex'; Parameters = @{ Account = 'S-1-1-0'; AccessRights = 'ReadData'; PassThru = $true } } + @{ Command = 'Clear-NTFSAudit'; Set = 'SD'; Parameters = @{} } + @{ Command = 'Get-NTFSAudit'; Set = 'SD'; Parameters = @{} } + @{ Command = 'Get-NTFSOrphanedAudit'; Set = 'SD'; Parameters = @{} } + ) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'NoAuditSection' + $sd = Get-TestAccessOnlyDescriptor -Path $file + + $result = @(& $Command -SecurityDescriptor $sd @Parameters -ErrorVariable auditErrors -ErrorAction SilentlyContinue) + + $result | Should -BeNullOrEmpty + $auditErrors | Should -HaveCount 1 + $auditErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadSecurityError,*' + $auditErrors[0].CategoryInfo.Category | Should -Be 'InvalidData' + $auditErrors[0].TargetObject | Should -Be $sd + $sd.SecurityDescriptor.GetSecurityDescriptorSddlForm('Audit') | Should -BeNullOrEmpty + } +} + +Describe 'Add-NTFSAudit' { + Context 'Binding -Path from the pipeline by property name' { + It 'Should add the audit entry for the piped object that exists, write a ReadFileError for the one that does not, and leave the DACL unchanged' -ForEach @( + @{ Set = 'PathComplex'; ExtraProperties = @{ InheritanceFlags = 'None'; PropagationFlags = 'None' } } + @{ Set = 'PathSimple'; ExtraProperties = @{ AppliesTo = 'ThisFolderOnly' } } + ) -Skip:(-not $canReadAudit) { + $missing = Join-Path -Path $sandbox -ChildPath ('AddPipeMissing-{0}' -f [guid]::NewGuid().ToString('N')) + Assert-TestSandboxPath -Sandbox $sandbox -Path $missing + $good = New-TestSandboxItem -Sandbox $sandbox -Name "AddPipeGood$Set" + $daclBefore = Get-TestDaclSddl -Path $good + + $items = @( + [pscustomobject] (@{ FullName = $missing; Account = $probe; AccessRights = 'ReadData' } + $ExtraProperties) + [pscustomobject] (@{ FullName = $good; Account = $probe; AccessRights = 'ReadData' } + $ExtraProperties) + ) + + $result = @($items | Add-NTFSAudit -PassThru -ErrorVariable auditErrors -ErrorAction SilentlyContinue) + + $auditErrors | Should -HaveCount 1 + $auditErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadFileError,*' + $auditErrors[0].CategoryInfo.Category | Should -Be 'OpenError' + $auditErrors[0].TargetObject | Should -Be $missing + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $good + (Get-TestAccountAuditRule -Acl (Get-Acl -LiteralPath $good -Audit) -Account $probe) | Should -HaveCount 1 + Get-TestDaclSddl -Path $good | Should -BeExactly $daclBefore + } + } + + 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' } } + @{ Set = 'SDComplex'; ExtraProperties = @{ InheritanceFlags = 'None'; PropagationFlags = 'None' } } + ) -Skip:(-not $canReadAudit) { + $badFile = New-TestSandboxItem -Sandbox $sandbox -Name "AddPipeSDBad$Set" + $sdBad = Get-TestAccessOnlyDescriptor -Path $badFile + $goodFile = New-TestSandboxItem -Sandbox $sandbox -Name "AddPipeSDGood$Set" + $auditBefore = Get-TestAuditSddl -Path $goodFile + $daclBefore = Get-TestDaclSddl -Path $goodFile + $sdGood = Get-NTFSSecurityDescriptor -Path $goodFile -ErrorAction Stop + + $items = @( + [pscustomobject] (@{ SecurityDescriptor = $sdBad; Account = $probe; AccessRights = 'ReadData' } + $ExtraProperties) + [pscustomobject] (@{ SecurityDescriptor = $sdGood; Account = $probe; AccessRights = 'ReadData' } + $ExtraProperties) + ) + + $result = @($items | Add-NTFSAudit -PassThru -ErrorVariable auditErrors -ErrorAction SilentlyContinue) + + $auditErrors | Should -HaveCount 1 + $auditErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadSecurityError,*' + $auditErrors[0].TargetObject | Should -Be $sdBad + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $goodFile + (Get-TestAccountAuditRule -Acl $sdGood.SecurityDescriptor -Account $probe) | Should -HaveCount 1 + Get-TestAuditSddl -Path $goodFile | Should -BeExactly $auditBefore + + Set-NTFSSecurityDescriptor -SecurityDescriptor $sdGood -ErrorAction Stop + + Get-TestAuditSddl -Path $goodFile | Should -Not -BeExactly $auditBefore + Get-TestDaclSddl -Path $goodFile | Should -BeExactly $daclBefore + } + } + + Context 'Conflicting parameters' { + It 'Should reject -AppliesTo combined with -InheritanceFlags with an AmbiguousParameterSet error and change nothing' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'AddAmbiguous' + $before = Get-TestDaclSddl -Path $file + + { Add-NTFSAudit -Path $file -Account $probe -AccessRights ReadData -AppliesTo ThisFolderOnly -InheritanceFlags None -ErrorAction Stop } | + Should -Throw -ErrorId 'AmbiguousParameterSet,NTFSSecurity.AddAudit' + + Get-TestDaclSddl -Path $file | Should -BeExactly $before + } + } + + Context 'Direct call of -SecurityDescriptor with -AppliesTo' { + # Add-NTFSAudit SDSimple (judged-audit.csv): every existing call binds it only through a splat + # (PermissionScopes.Tests.ps1) or through position (Audit.Tests.ps1); this one names the set's parameters + # directly. + It 'Should add the entry to the descriptor when -SecurityDescriptor, -Account, -AccessRights, and -AppliesTo are all named directly' -Skip:(-not $canReadAudit) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'AddDirectSDSimple' + $sd = Get-NTFSSecurityDescriptor -Path $file -ErrorAction Stop + + $result = @(Add-NTFSAudit -SecurityDescriptor $sd -Account $probe -AccessRights ReadData -AppliesTo ThisFolderOnly -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0] | Should -BeOfType [Security2.FileSystemAuditRule2] + (Get-TestAccountAuditRule -Acl $sd.SecurityDescriptor -Account $probe) | Should -HaveCount 1 + } + } +} + +Describe 'Remove-NTFSAudit' { + Context 'Binding -Path from the pipeline by property name' { + It "Should remove the audit entry for the piped object that exists, write a ReadFileError for the one that does not, and keep the other account's entry" -ForEach @( + @{ Set = 'PathComplex'; ExtraProperties = @{ InheritanceFlags = 'None'; PropagationFlags = 'None' } } + @{ Set = 'PathSimple'; ExtraProperties = @{ AppliesTo = 'ThisFolderOnly' } } + ) -Skip:(-not $canReadAudit) { + $missing = Join-Path -Path $sandbox -ChildPath ('RemovePipeMissing-{0}' -f [guid]::NewGuid().ToString('N')) + Assert-TestSandboxPath -Sandbox $sandbox -Path $missing + $good = New-TestSandboxItem -Sandbox $sandbox -Name "RemovePipeGood$Set" + Add-NTFSAudit -Path $good @entry -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + Add-NTFSAudit -Path $good -Account $keeper -AccessRights Delete -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + $daclBefore = Get-TestDaclSddl -Path $good + + $items = @( + [pscustomobject] (@{ FullName = $missing; Account = $probe; AccessRights = 'ReadData' } + $ExtraProperties) + [pscustomobject] (@{ FullName = $good; Account = $probe; AccessRights = 'ReadData' } + $ExtraProperties) + ) + + $result = @($items | Remove-NTFSAudit -PassThru -ErrorVariable auditErrors -ErrorAction SilentlyContinue) + + $auditErrors | Should -HaveCount 1 + $auditErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadFileError,*' + $auditErrors[0].CategoryInfo.Category | Should -Be 'OpenError' + $auditErrors[0].TargetObject | Should -Be $missing + $result | Should -Not -BeNullOrEmpty + $result | ForEach-Object -Process { $_.FullName | Should -Be $good } + (Get-TestAccountAuditRule -Acl (Get-Acl -LiteralPath $good -Audit) -Account $probe) | Should -BeNullOrEmpty + (Get-TestAccountAuditRule -Acl (Get-Acl -LiteralPath $good -Audit) -Account $keeper) | Should -HaveCount 1 + Get-TestDaclSddl -Path $good | Should -BeExactly $daclBefore + } + } + + Context 'Binding -SecurityDescriptor from the pipeline by property name' { + It "Should remove the audit entry for the piped object that has an audit section, write a ReadSecurityError for the one that does not, and keep the other account's entry" -ForEach @( + @{ Set = 'SDSimple'; ExtraProperties = @{ AppliesTo = 'ThisFolderOnly' } } + @{ Set = 'SDComplex'; ExtraProperties = @{ InheritanceFlags = 'None'; PropagationFlags = 'None' } } + ) -Skip:(-not $canReadAudit) { + $badFile = New-TestSandboxItem -Sandbox $sandbox -Name "RemovePipeSDBad$Set" + $sdBad = Get-TestAccessOnlyDescriptor -Path $badFile + $goodFile = New-TestSandboxItem -Sandbox $sandbox -Name "RemovePipeSDGood$Set" + Add-NTFSAudit -Path $goodFile @entry -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + Add-NTFSAudit -Path $goodFile -Account $keeper -AccessRights Delete -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + $sdGood = Get-NTFSSecurityDescriptor -Path $goodFile -ErrorAction Stop + + $items = @( + [pscustomobject] (@{ SecurityDescriptor = $sdBad; Account = $probe; AccessRights = 'ReadData' } + $ExtraProperties) + [pscustomobject] (@{ SecurityDescriptor = $sdGood; Account = $probe; AccessRights = 'ReadData' } + $ExtraProperties) + ) + + $result = @($items | Remove-NTFSAudit -PassThru -ErrorVariable auditErrors -ErrorAction SilentlyContinue) + + $auditErrors | Should -HaveCount 1 + $auditErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadSecurityError,*' + $auditErrors[0].TargetObject | Should -Be $sdBad + $result | Should -Not -BeNullOrEmpty + $result | ForEach-Object -Process { $_.FullName | Should -Be $goodFile } + (Get-TestAccountAuditRule -Acl $sdGood.SecurityDescriptor -Account $probe) | Should -BeNullOrEmpty + (Get-TestAccountAuditRule -Acl $sdGood.SecurityDescriptor -Account $keeper) | Should -HaveCount 1 + } + } + + Context 'Without -RemoveSpecific' { + # Judged-audit-notes.md: "the Modify entry keeps the other rights (probe first, then pin)". + It 'Should narrow an entry that grants more rights than it removes, instead of deleting it' -Skip:(-not $canReadAudit) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveNarrow' + Add-NTFSAudit -Path $file -Account $probe -AccessRights Modify -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + Add-NTFSAudit -Path $file -Account $keeper -AccessRights Delete -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + + Remove-NTFSAudit -Path $file @entry -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + + $probeRule = Get-TestAccountAuditRule -Acl (Get-Acl -LiteralPath $file -Audit) -Account $probe + $probeRule | Should -HaveCount 1 + $probeRule[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::ReadData) | Should -BeFalse + $probeRule[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::Delete) | Should -BeTrue + (Get-TestAccountAuditRule -Acl (Get-Acl -LiteralPath $file -Audit) -Account $keeper) | Should -HaveCount 1 + } + } + + 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. + It 'Should remove the entry from the path when -Path, -Account, -AccessRights, and -AppliesTo are all named directly' -Skip:(-not $canReadAudit) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveDirectPathSimple' + Add-NTFSAudit -Path $file -Account $probe -AccessRights ReadData -AppliesTo ThisFolderOnly -ErrorAction Stop + Add-NTFSAudit -Path $file -Account $keeper -AccessRights Delete -AppliesTo ThisFolderOnly -ErrorAction Stop + + $result = @(Remove-NTFSAudit -Path $file -Account $probe -AccessRights ReadData -AppliesTo ThisFolderOnly -PassThru -ErrorAction Stop) + + $result | Should -Not -BeNullOrEmpty + $result | ForEach-Object -Process { $_ | Should -BeOfType [Security2.FileSystemAuditRule2] } + (Get-TestAccountAuditRule -Acl (Get-Acl -LiteralPath $file -Audit) -Account $probe) | Should -BeNullOrEmpty + (Get-TestAccountAuditRule -Acl (Get-Acl -LiteralPath $file -Audit) -Account $keeper) | Should -HaveCount 1 + } + } + + Context 'Direct call of -SecurityDescriptor with -AppliesTo' { + # Remove-NTFSAudit SDSimple (judged-audit.csv): every existing call binds it only through a splat + # (PermissionScopes.Tests.ps1); this one names the set's parameters directly. + It 'Should remove the entry from the descriptor when -SecurityDescriptor, -Account, -AccessRights, and -AppliesTo are all named directly' -Skip:(-not $canReadAudit) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveDirectSDSimple' + $sd = Get-NTFSSecurityDescriptor -Path $file -ErrorAction Stop + Add-NTFSAudit -SecurityDescriptor $sd -Account $probe -AccessRights ReadData -AppliesTo ThisFolderOnly -ErrorAction Stop + Add-NTFSAudit -SecurityDescriptor $sd -Account $keeper -AccessRights Delete -AppliesTo ThisFolderOnly -ErrorAction Stop + + $result = @(Remove-NTFSAudit -SecurityDescriptor $sd -Account $probe -AccessRights ReadData -AppliesTo ThisFolderOnly -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-TestAccountAuditRule -Acl $sd.SecurityDescriptor -Account $keeper) | Should -HaveCount 1 + } + } +} + +Describe 'Clear-NTFSAudit' { + Context 'Taking paths from the pipeline' { + It 'Should clear the audit entries of each path taken from the pipeline and leave the DACL of each unchanged' -Skip:(-not $canReadAudit) { + $first = New-TestSandboxItem -Sandbox $sandbox -Name 'ClearPipeFirst' + $second = New-TestSandboxItem -Sandbox $sandbox -Name 'ClearPipeSecond' + foreach ($path in $first, $second) { + Add-NTFSAudit -Path $path @entry -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + } + $daclBefore = @((Get-TestDaclSddl -Path $first), (Get-TestDaclSddl -Path $second)) + + $first, $second | Clear-NTFSAudit -ErrorVariable clearErrors -ErrorAction SilentlyContinue + + $clearErrors | Should -BeNullOrEmpty + @(Get-NTFSAudit -Path $first -ExcludeInherited) | Should -BeNullOrEmpty + @(Get-NTFSAudit -Path $second -ExcludeInherited) | Should -BeNullOrEmpty + Get-TestDaclSddl -Path $first | Should -BeExactly $daclBefore[0] + Get-TestDaclSddl -Path $second | Should -BeExactly $daclBefore[1] + } + } + + Context 'With -DisableInheritance' { + # judged-audit.csv: "-DisableInheritance on a path is passed (Audit.Tests.ps1:629) but its end state + # (protected SACL, no copied inherited entries) is never asserted." + It "Should protect the SACL and not copy the parent's inherited entry when clearing a child path" -Skip:(-not $canReadAudit) { + $parent = New-TestSandboxItem -Sandbox $sandbox -Name 'ClearDisableParent' -Directory + Add-NTFSAudit -Path $parent -Account $keeper -AccessRights Delete -AppliesTo ThisFolderAndFiles -AuditFlags Success -ErrorAction Stop + $child = Join-Path -Path $parent -ChildPath 'Child.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $child + Set-Content -LiteralPath $child -Value 'Child' + Add-NTFSAudit -Path $child @entry -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + # Precondition: the parent's rule really is inherited onto the child before the test clears it. + @(Get-NTFSAudit -Path $child -ExcludeExplicit) | Should -HaveCount 1 + $daclBefore = Get-TestDaclSddl -Path $child + + Clear-NTFSAudit -Path $child -DisableInheritance -ErrorVariable clearErrors -ErrorAction SilentlyContinue + + $clearErrors | Should -BeNullOrEmpty + (Get-Acl -LiteralPath $child -Audit).AreAuditRulesProtected | Should -BeTrue + @((Get-Acl -LiteralPath $child -Audit).GetAuditRules($true, $true, $sidType)) | Should -BeNullOrEmpty + Get-TestDaclSddl -Path $child | Should -BeExactly $daclBefore + } + } + + Context 'Binding -SecurityDescriptor from the pipeline' { + It 'Should clear each piped descriptor that has an audit section and continue past one that does not' -Skip:(-not $canReadAudit) { + $badFile = New-TestSandboxItem -Sandbox $sandbox -Name 'ClearPipeSDBad' + $sdBad = Get-TestAccessOnlyDescriptor -Path $badFile + $goodFile = New-TestSandboxItem -Sandbox $sandbox -Name 'ClearPipeSDGood' + Add-NTFSAudit -Path $goodFile @entry -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + $sdGood = Get-NTFSSecurityDescriptor -Path $goodFile -ErrorAction Stop + $auditBefore = Get-TestAuditSddl -Path $goodFile + $daclBefore = Get-TestDaclSddl -Path $goodFile + + $sdBad, $sdGood | Clear-NTFSAudit -ErrorVariable clearErrors -ErrorAction SilentlyContinue + + $clearErrors | Should -HaveCount 1 + $clearErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadSecurityError,*' + $clearErrors[0].TargetObject | Should -Be $sdBad + @($sdGood.SecurityDescriptor.GetAuditRules($true, $false, $sidType)) | Should -BeNullOrEmpty + Get-TestAuditSddl -Path $goodFile | Should -BeExactly $auditBefore + + Set-NTFSSecurityDescriptor -SecurityDescriptor $sdGood -ErrorAction Stop + + @(Get-NTFSAudit -Path $goodFile -ExcludeInherited) | Should -BeNullOrEmpty + Get-TestDaclSddl -Path $goodFile | Should -BeExactly $daclBefore + } + } +} + +Describe 'Get-NTFSAudit' { + Context 'With several paths' { + # judged-audit.csv observation: Audit.Tests.ps1:70-73 no longer reproduces a failing path followed by one + # that succeeds (the deny entry blocks the DACL, not the SACL that the cmdlet reads). A missing path does. + It 'Should return the entries of the existing path in either position and a ReadFileError, category OpenError, for the missing one' -Skip:(-not $canReadAudit) { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'GetAuditFolder' -Directory + Add-NTFSAudit -Path $folder -Account $probe -AccessRights ReadData -AuditFlags Success -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + $missing = Join-Path -Path $sandbox -ChildPath ('GetAuditMissing-{0}' -f [guid]::NewGuid().ToString('N')) + Assert-TestSandboxPath -Sandbox $sandbox -Path $missing + + $forward = @(Get-NTFSAudit -Path $missing, $folder -ErrorVariable forwardErrors -ErrorAction SilentlyContinue) + $backward = @(Get-NTFSAudit -Path $folder, $missing -ErrorVariable backwardErrors -ErrorAction SilentlyContinue) + + foreach ($errors in $forwardErrors, $backwardErrors) { + $errors | Should -HaveCount 1 + $errors[0].FullyQualifiedErrorId | Should -BeLike 'ReadFileError,*' + $errors[0].CategoryInfo.Category | Should -Be 'OpenError' + $errors[0].TargetObject | Should -Be $missing + } + foreach ($result in $forward, $backward) { + $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]::Success) + $result[0].IsInherited | Should -BeFalse + $result[0].InheritanceEnabled | Should -Be (-not (Get-Acl -LiteralPath $folder -Audit).AreAuditRulesProtected) + } + } + } + + Context 'Taking paths from the pipeline' { + It 'Should return the entries of each path object taken from the pipeline by value' -Skip:(-not $canReadAudit) { + $first = New-TestSandboxItem -Sandbox $sandbox -Name 'GetAuditPipeFirst' + $second = New-TestSandboxItem -Sandbox $sandbox -Name 'GetAuditPipeSecond' + foreach ($path in $first, $second) { + Add-NTFSAudit -Path $path @entry -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + } + + $result = @(Get-Item2 -Path $first, $second | Get-NTFSAudit -ErrorAction Stop) + + ($result.FullName | Sort-Object) -join '|' | Should -Be (($first, $second | Sort-Object) -join '|') + } + } + + Context 'When the audit entries cannot be read' { + # judged-audit.csv: Audit.Tests.ps1:48 asserts the ErrorId only; this adds the category and the target. + It 'Should write a ReadSecurityError with category OpenError and the path as target without the Security privilege' -Skip:$canReadAudit { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'GetAuditNoPrivilege' + + $entries = @(Get-NTFSAudit -Path $file -ErrorVariable auditErrors -ErrorAction SilentlyContinue) + + $entries | Should -BeNullOrEmpty + $auditErrors | Should -HaveCount 1 + $auditErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadSecurityError,*' + $auditErrors[0].CategoryInfo.Category | Should -Be 'OpenError' + $auditErrors[0].TargetObject | Should -Be $file + } + } + + Context 'Binding -SecurityDescriptor from the pipeline' { + It 'Should return the 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 'GetAuditSDBad' + $sdBad = Get-TestAccessOnlyDescriptor -Path $badFile + $goodFile = New-TestSandboxItem -Sandbox $sandbox -Name 'GetAuditSDGood' + Add-NTFSAudit -Path $goodFile @entry -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + $expected = @(Get-NTFSAudit -Path $goodFile -ErrorAction Stop | ForEach-Object -Process { '{0}|{1}|{2}' -f $_.Account.Sid, $_.AccessRights, $_.AuditFlags }) + $sdGood = Get-NTFSSecurityDescriptor -Path $goodFile -ErrorAction Stop + + $result = @($sdBad, $sdGood | Get-NTFSAudit -ErrorVariable auditErrors -ErrorAction SilentlyContinue) + + $auditErrors | Should -HaveCount 1 + $auditErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadSecurityError,*' + $auditErrors[0].CategoryInfo.Category | Should -Be 'InvalidData' + $auditErrors[0].TargetObject | Should -Be $sdBad + $result | Should -Not -BeNullOrEmpty + ($result | ForEach-Object -Process { '{0}|{1}|{2}' -f $_.Account.Sid, $_.AccessRights, $_.AuditFlags }) -join ';' | Should -Be ($expected -join ';') + } + } +} + +Describe 'Get-NTFSOrphanedAudit' { + Context 'Taking paths from the pipeline' { + It 'Should return the orphaned entries of each path taken from the pipeline and write a ReadError, category OpenError, for one that does not exist' -Skip:(-not $canReadAudit) { + $orphaned = New-TestSandboxItem -Sandbox $sandbox -Name 'OrphanedPipe' + Add-NTFSAudit -Path $orphaned -Account 'S-1-5-21-1-2-3-9001' -AccessRights ReadData -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + $normal = New-TestSandboxItem -Sandbox $sandbox -Name 'NormalPipe' + Add-NTFSAudit -Path $normal @entry -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + $missing = Join-Path -Path $sandbox -ChildPath ('OrphanedMissing-{0}' -f [guid]::NewGuid().ToString('N')) + Assert-TestSandboxPath -Sandbox $sandbox -Path $missing + + $result = @($missing, $orphaned, $normal | Get-NTFSOrphanedAudit -ErrorVariable orphanedErrors -ErrorAction SilentlyContinue) + + $orphanedErrors | Should -HaveCount 1 + $orphanedErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadError,*' + $orphanedErrors[0].CategoryInfo.Category | Should -Be 'OpenError' + $orphanedErrors[0].TargetObject | Should -Be $missing + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $orphaned + $result[0].Account.Sid | Should -Be 'S-1-5-21-1-2-3-9001' + } + } + + 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' + $sdBad = Get-TestAccessOnlyDescriptor -Path $badFile + $orphanedFile = New-TestSandboxItem -Sandbox $sandbox -Name 'OrphanedSDGood' + Add-NTFSAudit -Path $orphanedFile -Account 'S-1-5-21-1-2-3-9002' -AccessRights ReadData -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + $sdOrphaned = Get-NTFSSecurityDescriptor -Path $orphanedFile -ErrorAction Stop + + $result = @($sdBad, $sdOrphaned | Get-NTFSOrphanedAudit -ErrorVariable orphanedErrors -ErrorAction SilentlyContinue) + + $orphanedErrors | Should -HaveCount 1 + $orphanedErrors[0].TargetObject | Should -Be $sdBad + $result | Should -HaveCount 1 + $result[0] | Should -BeOfType [Security2.FileSystemAuditRule2] + $result[0].Account.Sid | Should -Be 'S-1-5-21-1-2-3-9002' + $result[0].FullName | Should -Be $orphanedFile + } + } + + Context 'Direct call of -SecurityDescriptor' { + # Get-NTFSOrphanedAudit SD (judged-audit.csv): both existing calls bind it only because PowerShell resolves + # a piped object with a SecurityDescriptor property to this parameter (Audit.Tests.ps1:243, :294); this one + # names it directly. + It 'Should return the orphaned entries when -SecurityDescriptor is named directly' -Skip:(-not $canReadAudit) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'OrphanedDirectSD' + Add-NTFSAudit -Path $file -Account 'S-1-5-21-1-2-3-9003' -AccessRights ReadData -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + $sd = Get-NTFSSecurityDescriptor -Path $file -ErrorAction Stop + + $result = @(Get-NTFSOrphanedAudit -SecurityDescriptor $sd -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0] | Should -BeOfType [Security2.FileSystemAuditRule2] + $result[0].Account.Sid | Should -Be 'S-1-5-21-1-2-3-9003' + $result[0].FullName | Should -Be $file + } + } +}