Test/Measure-OwnerAnomaly.Tests.ps1
|
using namespace System using namespace System.IO using namespace System.Security.Cryptography using namespace System.Collections.Immutable Set-StrictMode -Version Latest # Evaluated at discovery time so -Skip resolves while Pester builds the tree. $IsElevatedHost = $false if ($IsWindows) { $principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() $IsElevatedHost = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } BeforeAll { $moduleRoot = Split-Path -Path $PSScriptRoot -Parent Import-Module (Join-Path -Path $moduleRoot -ChildPath 'PSSecurity.psd1') -Force } AfterAll { Remove-Module PSSecurity -Force -ErrorAction SilentlyContinue } #### <h2 style="color: #DCA657;">Measure-OwnerAnomaly</h2> #### Describe 'Measure-OwnerAnomaly' -Skip:(-not $IsWindows) { BeforeAll { $script:dir = Join-Path ([IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString()) New-Item -ItemType Directory -Path $script:dir | Out-Null foreach ($leaf in 'one.txt', 'two.txt', 'three.txt') { Set-Content -LiteralPath (Join-Path $script:dir $leaf) -Value 'x' -NoNewline } $script:items = @(Get-ChildItem -LiteralPath $script:dir -File | ForEach-Object { $_.FullName | Get-AclItem }) # Read the owner off disk rather than assuming it. Local policy decides # whether an admin-created file is owned by the account or by Administrators. $script:actualOwner = $script:items[0].Owner $script:foreignIdentity = 'NOSUCHDOMAIN\nosuchprincipal' } AfterAll { Remove-Item -LiteralPath $script:dir -Recurse -Force -ErrorAction SilentlyContinue } #### #### <b style="color: #D2A8FF;">Cases</b> #### #### - The function is exported, and is a function rather than an alias. It 'Is exported as a function' { $cmd = Get-Command -Module PSSecurity -Name 'Measure-OwnerAnomaly' -ErrorAction SilentlyContinue $cmd | Should -Not -BeNullOrEmpty $cmd.CommandType | Should -Be 'Function' } #### - One object for the whole pipeline. This is the accumulator contract. It 'Emits exactly one summary object for the whole pipeline' { # Regression: an accumulator backed by ArrayList leaks Add()'s index # onto the success stream, one integer per input, ahead of the summary. @($script:items | Measure-OwnerAnomaly).Count | Should -Be 1 } #### - Every input is counted, whether or not it was an anomaly. It 'Counts every item that came down the pipeline' { ($script:items | Measure-OwnerAnomaly).AclItemCount | Should -Be $script:items.Count } #### - A matching identity produces no anomalies at all. It 'Reports no anomaly when the owner matches the identity' { $result = $script:items | Measure-OwnerAnomaly -Identity $script:actualOwner $result.AnomalyCount | Should -Be 0 @($result.Anomalies).Count | Should -Be 0 } #### - An identity that owns nothing makes every item an anomaly. It 'Reports every item when the identity does not match the owner' { $result = $script:items | Measure-OwnerAnomaly -Identity $script:foreignIdentity $result.AnomalyCount | Should -Be $script:items.Count } #### - `-ne` on strings is case insensitive in PowerShell, so a shouted identity #### still matches. Anything that lowercases one side only would pass this too. It 'Compares the owner without regard to case' { $result = $script:items | Measure-OwnerAnomaly -Identity $script:actualOwner.ToUpper() $result.AnomalyCount | Should -Be 0 } #### - `Anomalies` holds the offending items themselves, so the summary is #### actionable rather than just a count. It 'Carries the offending AclItem objects in Anomalies' { $result = $script:items | Measure-OwnerAnomaly -Identity $script:foreignIdentity $anomalies = @($result.Anomalies) $anomalies[0].FullName | Should -Not -BeNullOrEmpty $anomalies[0].Owner | Should -Be $script:actualOwner } #### - `AclItems` retains the full input alongside the filtered set. It 'Retains every input in AclItems alongside the anomalies' { $result = $script:items | Measure-OwnerAnomaly -Identity $script:foreignIdentity @($result.AclItems).Count | Should -Be $script:items.Count } } #### #### --- #### #### <h2 style="color: #DCA657;">Measure-OwnerAnomaly elevated</h2> #### #### Changing an owner needs elevation, so the one case that starts by doing that #### is gated. The Administrators account name is resolved from its well known SID #### rather than hardcoded, so it passes on a non English Windows install. #### Describe 'Measure-OwnerAnomaly elevated' -Skip:(-not $IsElevatedHost) { BeforeAll { $script:dir = Join-Path ([IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString()) New-Item -ItemType Directory -Path $script:dir | Out-Null $script:fileA = Join-Path $script:dir 'a.txt' $script:fileB = Join-Path $script:dir 'b.txt' Set-Content -LiteralPath $script:fileA -Value 'a' -NoNewline Set-Content -LiteralPath $script:fileB -Value 'b' -NoNewline $script:me = "$($env:USERDOMAIN)\$($env:USERNAME)" $script:administrators = ([Security.Principal.SecurityIdentifier]::new( [Security.Principal.WellKnownSidType]::BuiltinAdministratorsSid, $null )).Translate([Security.Principal.NTAccount]).Value } AfterAll { Remove-Item -LiteralPath $script:dir -Recurse -Force -ErrorAction SilentlyContinue } #### #### <b style="color: #D2A8FF;">Cases</b> #### #### - An item whose owner was changed out from under the current user is #### reported as an anomaly, and the offending owner is named. It 'Flags an item owned by another principal' { $script:fileA | Get-AclItem | Set-AclItemOwner -Identity $script:administrators | Out-Null $result = @($script:fileA, $script:fileB) | Get-AclItem | Measure-OwnerAnomaly -Identity $script:me $result.AnomalyCount | Should -BeGreaterThan 0 @($result.Anomalies).Owner | Should -Contain $script:administrators } } #### #### --- #### |