diff --git a/Tests/Lab/Acceptance/Invoke-EffectiveAccessProbe.ps1 b/Tests/Lab/Acceptance/Invoke-EffectiveAccessProbe.ps1 new file mode 100644 index 0000000..db317d4 --- /dev/null +++ b/Tests/Lab/Acceptance/Invoke-EffectiveAccessProbe.ps1 @@ -0,0 +1,93 @@ +[CmdletBinding()] +param ( + [Parameter(Mandatory)] [string] $ModulePath, + [Parameter(Mandatory)] [string] $OutFile, + [Parameter(Mandatory)] [string] $Variant, + [string] $OtherServer = '' +) + +# Runs inside a machine of the operating-system matrix, in Windows PowerShell 5.1 under the token that Probe-EffectiveAccess.ps1 chose for +# the variant, and asks Get-NTFSEffectiveAccess the same question in several ways: for the account of the token, Everyone, the local +# Administrator, and the domain Administrator and Domain Users on a computer in a domain, each for the default server name, localhost, an +# empty name, the names of this computer, and the computers of -OtherServer (a comma-separated list). For every call it writes the result, +# the number of warnings, and the native error with the failing method, so that the failing call of the authorization manager shows. It +# changes nothing but a folder below $env:TEMP. +$ErrorActionPreference = 'Continue' +$ProgressPreference = 'SilentlyContinue' +$stamp = '[{0:HH:mm:ss}]' +function Write-Probe { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $OutFile } + +$null = New-Item -ItemType Directory -Path (Split-Path -Path $OutFile -Parent) -Force +Set-Content -LiteralPath $OutFile -Value '' +try { + Import-Module -Name (Join-Path -Path $ModulePath -ChildPath 'NTFSSecurity.psd1') -Force -ErrorAction Stop + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object -TypeName 'Security.Principal.WindowsPrincipal' -ArgumentList $identity + $current = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' + Write-Probe ('START variant={0} user={1} sid={2} administrator={3} os={4} {5}.{6} dll={7}' -f $Variant, $identity.Name, $identity.User.Value, + $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator), $current.ProductName, $current.CurrentBuildNumber, $current.UBR, + (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path -Path $ModulePath -ChildPath 'NTFSSecurity.dll')).Hash.Substring(0, 12)) + $groups = @(& whoami.exe /groups /fo csv | ConvertFrom-Csv) + Write-Probe ('groups={0}; deny only: {1}' -f $groups.Count, ((@($groups | Where-Object -FilterScript { $_.Attributes -match 'deny' } | ForEach-Object -Process { $_.'Group Name' })) -join ', ')) + Write-Probe ('integrity: {0}' -f ((@($groups | Where-Object -FilterScript { $_.'Group Name' -like 'Mandatory Label*' } | ForEach-Object -Process { $_.'Group Name' })) -join ', ')) + $privileges = @(& whoami.exe /priv /fo csv | ConvertFrom-Csv) + Write-Probe ('privileges present={0} enabled: {1}' -f $privileges.Count, ((@($privileges | Where-Object -FilterScript { $_.State -eq 'Enabled' } | ForEach-Object -Process { $_.'Privilege Name' })) -join ', ')) + + $folder = Join-Path -Path $env:TEMP -ChildPath ('probe-{0}' -f [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $folder + $fqdn = try { [Net.Dns]::GetHostEntry($env:COMPUTERNAME).HostName } catch { $env:COMPUTERNAME } + function Resolve-Sid { + param ([string] $Name) + try { (New-Object -TypeName 'Security.Principal.NTAccount' -ArgumentList $Name).Translate([Security.Principal.SecurityIdentifier]).Value } catch { '' } + } + + $computer = Get-CimInstance -ClassName Win32_ComputerSystem + Write-Probe ('computer {0} domain joined={1} domain={2}' -f $env:COMPUTERNAME, $computer.PartOfDomain, $computer.Domain) + $accounts = [ordered]@{ 'self' = ''; 'Everyone' = 'S-1-1-0'; 'local Administrator' = (Resolve-Sid -Name ('{0}\Administrator' -f $env:COMPUTERNAME)) } + if ($computer.PartOfDomain) { + $accounts['domain Administrator'] = Resolve-Sid -Name ('{0}\Administrator' -f $computer.Domain) + $accounts['Domain Users'] = Resolve-Sid -Name ('{0}\Domain Users' -f $computer.Domain) + } + + $servers = [ordered]@{ 'no -ServerName' = $null; 'localhost' = 'localhost'; 'empty name' = ''; 'computer name' = $env:COMPUTERNAME; 'fqdn' = $fqdn } + foreach ($name in @($OtherServer -split ',' | Where-Object -FilterScript { $_ })) { $servers["other computer $name"] = $name } + $cases = foreach ($accountName in $accounts.Keys) { + if ($accountName -ne 'self' -and -not $accounts[$accountName]) { continue } + foreach ($serverName in $servers.Keys) { + $arguments = @{} + if ($accounts[$accountName]) { $arguments.Account = $accounts[$accountName] } + if ($null -ne $servers[$serverName]) { $arguments.ServerName = $servers[$serverName] } + @{ Name = ('{0}, {1}' -f $accountName, $serverName); Arguments = $arguments } + } + } + + foreach ($case in $cases) { + $arguments = $case.Arguments + $errorList = $null + $warningList = $null + try { + $result = @(Get-NTFSEffectiveAccess -Path $folder @arguments -ErrorVariable errorList -WarningVariable warningList -ErrorAction SilentlyContinue -WarningAction SilentlyContinue) + } + catch { + $result = @() + $errorList = @($_) + } + + $access = if ($result.Count -gt 0) { ('{0}' -f $result[0].AccessRights) } else { 'none' } + $warnings = @($warningList | ForEach-Object -Process { ('{0}' -f $_.Message) -replace '\s+', ' ' } | ForEach-Object -Process { if ($_.Length -gt 60) { $_.Substring(0, 60) } else { $_ } }) + Write-Probe ('CASE {0}: results={1} access={2} errors={3} warnings={4}' -f $case.Name, $result.Count, $access, @($errorList).Count, $warnings.Count) + foreach ($record in @($errorList)) { + $inner = $record.Exception + while ($inner.InnerException) { $inner = $inner.InnerException } + $native = if ($inner -is [ComponentModel.Win32Exception]) { $inner.NativeErrorCode } else { '' } + $frames = (('{0}' -f $inner.StackTrace) -split "`r?`n" | Select-Object -First 1 | ForEach-Object -Process { $_.Trim() -replace '^at ', '' -replace '\(.*$', '' }) -join ' <- ' + Write-Probe (' ERROR id={0} type={1} native={2} message={3} frames={4}' -f $record.FullyQualifiedErrorId, $inner.GetType().Name, $native, $inner.Message, $frames) + } + } + + Remove-Item -LiteralPath $folder -Recurse -Force -ErrorAction SilentlyContinue + Write-Probe 'DONE' +} +catch { + Write-Probe ('FAILED: {0}' -f $_) +} diff --git a/Tests/Lab/Acceptance/Probe-EffectiveAccess.ps1 b/Tests/Lab/Acceptance/Probe-EffectiveAccess.ps1 new file mode 100644 index 0000000..93c3bf1 --- /dev/null +++ b/Tests/Lab/Acceptance/Probe-EffectiveAccess.ps1 @@ -0,0 +1,268 @@ +[CmdletBinding()] +param ( + [Parameter(Mandatory)] [ValidatePattern('^[\w-]+$')] [string] $Label, + [Parameter(Mandatory)] [string] $Machine, + [Parameter(Mandatory)] [string] $ModulePath, + [Parameter(Mandatory)] [string] $OutputRoot, + [string] $Variant = 'Elevated,Safer,Standard', + [string] $OtherServer = '', + [string] $DomainController = 'OSDC1', + [string] $LabName = 'NtfsSecurityOsMatrixLab', + [string] $LocalCredentialMachine = '', + [string] $RepositoryRoot, + [ValidateRange(1, 60)] [int] $TimeoutMinutes = 10 +) + +# Diagnostic of the operating-system matrix (Decision 24), in Windows PowerShell 5.1 on the Hyper-V host: runs Invoke-EffectiveAccessProbe.ps1 +# on one machine under up to four tokens, one after the other, and copies the output back. +# Elevated the lab account in a scheduled task at the highest run level (the elevated mode of the local suite) +# Limited the same account at the limited run level; a task with a batch logon doesn't get a filtered token, so this repeats Elevated +# Safer the token of a basic user that Run-MatrixLocalSuite.ps1 -Mode Basic uses (SAFER level Normal User) +# Standard a local standard user that this script creates on the machine and removes again, with a password that only exists there +# DomainStandard a standard user of the domain, created on the domain controller (-DomainController) and removed again +# The standard users get the batch logon right through the group Performance Log Users, which has no other right that the check needs. +# Nothing secret is written; the lab password stays in memory, as in Run-MatrixLocalSuite.ps1. +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +# Windows PowerShell 5.1 leaves $PSScriptRoot empty in a parameter default when the script runs with -File. +if (-not $RepositoryRoot) { $RepositoryRoot = Split-Path -Path (Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent) -Parent } +$variants = @($Variant -split ',' | Where-Object -FilterScript { $_ }) +if ($variants | Where-Object -FilterScript { $_ -notin 'Elevated', 'Limited', 'Safer', 'Standard', 'DomainStandard' }) { throw '-Variant takes Elevated, Limited, Safer, Standard, and DomainStandard, separated by commas.' } +$localCredential = @($LocalCredentialMachine -split ',' | Where-Object -FilterScript { $_ }) +$cellFolder = Join-Path -Path $OutputRoot -ChildPath "$Label-$Machine" +$null = New-Item -ItemType Directory -Path $cellFolder -Force +$log = Join-Path -Path $cellFolder -ChildPath "$Label-probe.log" +$stamp = '[{0:yyyy-MM-dd HH:mm:ss}Z]' +function Write-Step { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $log } +Set-Content -LiteralPath $log -Value (($stamp -f [DateTime]::UtcNow) + " START probe-$Label machine=$Machine variants=$($variants -join ',')") + +$wrapper = Get-Content -LiteralPath (Join-Path -Path $RepositoryRoot -ChildPath '.github\scripts\Invoke-TestsAsBasicUser.ps1') -Raw +$class = [regex]::Match($wrapper, "(?s)Add-Type -TypeDefinition @'\r?\n(.*?)\r?\n'@").Groups[1].Value +if (-not $class) { throw 'The class of the basic-user wrapper was not found in .github\scripts\Invoke-TestsAsBasicUser.ps1.' } +$saferHead = @' +[CmdletBinding()] +param ( + [Parameter(Mandatory)] [string] $Executable, + [Parameter(Mandatory)] [string] $Arguments, + [Parameter(Mandatory)] [string] $WorkDirectory, + [Parameter(Mandatory)] [string] $Console +) + +# Generated by Probe-EffectiveAccess.ps1: starts a process with the token of a basic user (SAFER level Normal User) through the class of +# .github\scripts\Invoke-TestsAsBasicUser.ps1 and waits for it. +$ErrorActionPreference = 'Stop' +'@ +$saferTail = @' +$commandLine = 'cmd.exe /d /s /c ""{0}" {1} > "{2}" 2>&1"' -f $Executable, $Arguments, $Console +exit [NTFSSecurityBasicUserProcess]::Run((Join-Path -Path $env:SystemRoot -ChildPath 'System32\cmd.exe'), $commandLine, $WorkDirectory) +'@ +$stage = Join-Path -Path $env:TEMP -ChildPath "ntfs-probe-$Label" +if (Test-Path -LiteralPath $stage) { Remove-Item -LiteralPath $stage -Recurse -Force } +$null = New-Item -ItemType Directory -Path (Join-Path -Path $stage -ChildPath 'module') -Force +Copy-Item -Path (Join-Path -Path $ModulePath -ChildPath '*') -Destination (Join-Path -Path $stage -ChildPath 'module') -Recurse +Copy-Item -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath 'Invoke-EffectiveAccessProbe.ps1') -Destination $stage +Set-Content -LiteralPath (Join-Path -Path $stage -ChildPath 'Start-SaferProcess.ps1') -Encoding UTF8 -Value ($saferHead + "`r`nAdd-Type -TypeDefinition @'`r`n" + $class + "`r`n'@`r`n" + $saferTail) +$dllHash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path -Path $ModulePath -ChildPath 'NTFSSecurity.dll')).Hash +Write-Step "module dll=$dllHash" + +Import-Module -Name AutomatedLab -ErrorAction Stop +Import-Lab -Name $LabName -NoValidation -NoDisplay +$sessionParameters = @{ ComputerName = $Machine } +if ($Machine -in $localCredential) { $sessionParameters.UseLocalCredential = $true } +$session = New-LabPSSession @sessionParameters +$machineDefinition = Get-LabVM -ComputerName $Machine +$runCredential = if ($Machine -in $localCredential) { + New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList ('{0}\{1}' -f $Machine, $machineDefinition.InstallationUser.UserName), (ConvertTo-SecureString -String $machineDefinition.InstallationUser.Password -AsPlainText -Force) +} +else { + $machineDefinition.GetCredential((Get-Lab)) +} + +$root = 'C:\NtfsMatrixProbe\' + $Label +# A new name for every run: Windows keeps the SID of a deleted account for its name for a while, and a profile that stays loaded keeps the +# folder, so a name that is used again meets the leftovers of its predecessor. +$suffix = [DateTime]::UtcNow.ToString('MMddHHmmss') +$standardUser = 'NtfsProbeS' + $suffix +$domainUser = 'NtfsProbeD' + $suffix +$dcSession = $null +$domainSid = '' +function Get-RandomProbePassword { + # Random and never written; it exists in memory and in the account that the probe removes. + $bytes = New-Object -TypeName 'byte[]' -ArgumentList 24 + [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) + $alphabet = [char[]] 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789' + 'Aa1!' + (-join ($bytes | ForEach-Object -Process { $alphabet[$_ % $alphabet.Length] })) +} +try { + Invoke-Command -Session $session -ArgumentList $root -ScriptBlock { + param ($Path) + if (Test-Path -LiteralPath $Path) { Remove-Item -LiteralPath $Path -Recurse -Force } + $null = New-Item -ItemType Directory -Path (Join-Path -Path $Path -ChildPath 'out') -Force + } + + Copy-Item -Path (Join-Path -Path $stage -ChildPath '*') -Destination $root -ToSession $session -Recurse -Force + Write-Step "staged to $root" + + $start = { + param ($Root, $Variant, $UserName, $Password, $OtherServer, $StandardUser, $DomainSid) + $powershell = Join-Path -Path $env:SystemRoot -ChildPath 'System32\WindowsPowerShell\v1.0\powershell.exe' + $out = Join-Path -Path $Root -ChildPath 'out' + $outFile = Join-Path -Path $out -ChildPath ('{0}.txt' -f $Variant) + $probe = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{0}" -ModulePath "{1}" -OutFile "{2}" -Variant {3}' -f (Join-Path -Path $Root -ChildPath 'Invoke-EffectiveAccessProbe.ps1'), + (Join-Path -Path $Root -ChildPath 'module'), $outFile, $Variant + if ($OtherServer) { $probe += ' -OtherServer "{0}"' -f $OtherServer } + $taskName = 'NtfsMatrixProbe-' + $Variant + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + $runLevel = 'Highest' + if ($Variant -eq 'Standard') { + # The password exists only here: random, never written, and the account is removed after the run. + $bytes = New-Object -TypeName 'byte[]' -ArgumentList 24 + $generator = [Security.Cryptography.RandomNumberGenerator]::Create() + $generator.GetBytes($bytes) + $alphabet = [char[]] 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789' + $Password = 'Aa1!' + (-join ($bytes | ForEach-Object -Process { $alphabet[$_ % $alphabet.Length] })) + $UserName = '{0}\{1}' -f $env:COMPUTERNAME, $StandardUser + if (Get-LocalUser -Name $StandardUser -ErrorAction SilentlyContinue) { Remove-LocalUser -Name $StandardUser } + $null = New-LocalUser -Name $StandardUser -Password (ConvertTo-SecureString -String $Password -AsPlainText -Force) -PasswordNeverExpires -UserMayNotChangePassword -Description 'Probe of the matrix, removed after the run' + Add-LocalGroupMember -SID 'S-1-5-32-559' -Member $StandardUser + $null = & icacls.exe $out /grant ('{0}:(OI)(CI)M' -f $StandardUser) + $runLevel = 'Limited' + $execute = $powershell + $argument = $probe + } + elseif ($Variant -eq 'DomainStandard') { + # By SID: a name of a deleted account of an earlier run can still resolve to its old SID. + try { Add-LocalGroupMember -SID 'S-1-5-32-559' -Member $DomainSid } + catch { if ($_.Exception.GetType().Name -ne 'MemberExistsException') { throw } } + $null = & icacls.exe $out /grant ('{0}:(OI)(CI)M' -f $UserName) + $runLevel = 'Limited' + $execute = $powershell + $argument = $probe + } + elseif ($Variant -eq 'Limited') { + $runLevel = 'Limited' + $execute = $powershell + $argument = $probe + } + elseif ($Variant -eq 'Safer') { + $null = & icacls.exe $out /grant ('{0}:(OI)(CI)M' -f $UserName) + $execute = $powershell + $argument = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{0}" -Executable "{1}" -Arguments "{2}" -WorkDirectory "{3}" -Console "{4}"' -f (Join-Path -Path $Root -ChildPath 'Start-SaferProcess.ps1'), + $powershell, ($probe -replace '"', '\"'), $Root, (Join-Path -Path $out -ChildPath 'safer.console.txt') + } + else { + $execute = $powershell + $argument = $probe + } + + $action = New-ScheduledTaskAction -Execute $execute -Argument $argument + $null = Register-ScheduledTask -TaskName $taskName -Action $action -RunLevel $runLevel -User $UserName -Password $Password + Start-ScheduledTask -TaskName $taskName + $taskName + } + $isRunning = { + param ($TaskName) + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + [bool] ($task -and $task.State -eq 'Running') + } + $finish = { + param ($TaskName) + $result = (Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue).LastTaskResult + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue + "task result $result" + } + + foreach ($name in $variants) { + $password = if ($name -in 'Elevated', 'Limited', 'Safer') { $runCredential.GetNetworkCredential().Password } else { '' } + $userName = if ($name -in 'Elevated', 'Limited', 'Safer') { $runCredential.UserName } else { '' } + if ($name -eq 'DomainStandard') { + if (-not $dcSession) { $dcSession = New-LabPSSession -ComputerName $DomainController } + $password = Get-RandomProbePassword + $domainSid = Invoke-Command -Session $dcSession -ArgumentList $domainUser, $password -ScriptBlock { + param ($Name, $Secret) + Import-Module -Name ActiveDirectory + New-ADUser -Name $Name -SamAccountName $Name -AccountPassword (ConvertTo-SecureString -String $Secret -AsPlainText -Force) -Enabled $true -PasswordNeverExpires $true -CannotChangePassword $true -Description 'Probe of the matrix, removed after the run' + (Get-ADUser -Identity $Name).SID.Value + } + $userName = '{0}\{1}' -f ((Get-Lab).Domains[0].Name -split '\.')[0], $domainUser + Write-Step "domain user $domainUser created ($domainSid)" + } + $taskName = Invoke-Command -Session $session -ScriptBlock $start -ArgumentList $root, $name, $userName, $password, $OtherServer, $standardUser, $domainSid + Write-Step "variant $name started ($taskName)" + $deadline = [DateTime]::UtcNow.AddMinutes($TimeoutMinutes) + do { + Start-Sleep -Seconds 5 + $alive = Invoke-Command -Session $session -ScriptBlock $isRunning -ArgumentList $taskName + } while ($alive -and [DateTime]::UtcNow -lt $deadline) + if ($alive) { Write-Step "variant $name TIMED OUT after $TimeoutMinutes minutes" } + Write-Step ("variant $name finished: " + (Invoke-Command -Session $session -ScriptBlock $finish -ArgumentList $taskName)) + } + + Copy-Item -FromSession $session -Path (Join-Path -Path $root -ChildPath 'out\*') -Destination $cellFolder -Recurse -Force + Write-Step 'results copied back' +} +finally { + # The domain account goes first: its member entry on the machine is then an orphaned SID, which the cleanup of the machine removes. + if ($dcSession) { + $dcLeftOver = Invoke-Command -Session $dcSession -ArgumentList $domainUser -ScriptBlock { + param ($Name) + Import-Module -Name ActiveDirectory + if (Get-ADUser -Filter "SamAccountName -eq '$Name'") { Remove-ADUser -Identity $Name -Confirm:$false } + if (Get-ADUser -Filter "SamAccountName -eq '$Name'") { "domain user $Name still exists" } + } + Write-Step ('cleanup of the domain controller: ' + $(if (@($dcLeftOver).Count -eq 0) { 'nothing left' } else { @($dcLeftOver) -join '; ' })) + Remove-PSSession -Session $dcSession -ErrorAction SilentlyContinue + } + + if ($session) { + # The accounts and the files of the probe don't stay on the machine. The cleanup finds them by name and by orphaned SID, not by + # what this run created, so it also repairs what a run that stopped early left. + $leftOver = Invoke-Command -Session $session -ArgumentList $root, $standardUser, $domainSid -ScriptBlock { + param ($Root, $StandardUser, $DomainSid) + $report = New-Object -TypeName 'System.Collections.Generic.List[string]' + $users = Join-Path -Path $env:SystemDrive -ChildPath 'Users' + function Get-ProbeProfile { @(Get-CimInstance -ClassName Win32_UserProfile | Where-Object -FilterScript { $_.LocalPath -like (Join-Path -Path $users -ChildPath 'NtfsProbe*') }) } + function Get-ProbeMember { + # net.exe shows the member of a deleted account as its SID. + foreach ($line in @(cmd.exe /d /c 'net localgroup "Performance Log Users" 2>&1')) { + $member = ('{0}' -f $line).Trim() + if ($member -match '^S-1-5-21-[\d-]+$' -or $member -match '\\NtfsProbe') { $member } + } + } + + @(Get-ScheduledTask -TaskName 'NtfsMatrixProbe-*' -ErrorAction SilentlyContinue) | ForEach-Object -Process { Unregister-ScheduledTask -TaskName $_.TaskName -Confirm:$false -ErrorAction SilentlyContinue } + if (Get-LocalUser -Name $StandardUser -ErrorAction SilentlyContinue) { Remove-LocalUser -Name $StandardUser } + # net.exe doesn't take the SID of an account that its name cache still resolves, so the member goes by its SID through the cmdlet. + if ($DomainSid) { + try { Remove-LocalGroupMember -SID 'S-1-5-32-559' -Member $DomainSid -ErrorAction Stop } + catch { if ("$($_.Exception.Message)" -notlike '*was not found*') { $report.Add("member ${DomainSid}: $($_.Exception.Message)") } } + } + + # A profile that the last task of the account used stays loaded for a few seconds, so the removal is repeated. + $attempt = 0 + do { + $profiles = Get-ProbeProfile + if ($profiles.Count -gt 0) { + $profiles | Remove-CimInstance -ErrorAction SilentlyContinue + if ((Get-ProbeProfile).Count -gt 0) { Start-Sleep -Seconds 3 } + } + + $attempt++ + } while ((Get-ProbeProfile).Count -gt 0 -and $attempt -lt 10) + + Get-ChildItem -LiteralPath $users -Filter 'NtfsProbe*' -Force -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $Root) { Remove-Item -LiteralPath $Root -Recurse -Force -ErrorAction SilentlyContinue } + if (Get-LocalUser -Name $StandardUser -ErrorAction SilentlyContinue) { $report.Add("user $StandardUser still exists") } + foreach ($member in @(Get-ProbeMember)) { $report.Add("$member is still in Performance Log Users") } + foreach ($folder in @(Get-ChildItem -LiteralPath $users -Filter 'NtfsProbe*' -Force -ErrorAction SilentlyContinue)) { $report.Add("profile folder $($folder.Name) still exists") } + foreach ($userProfile in (Get-ProbeProfile)) { $report.Add("profile $($userProfile.LocalPath) still exists") } + if (Test-Path -LiteralPath $Root) { $report.Add("folder $Root still exists") } + @(Get-ScheduledTask -TaskName 'NtfsMatrixProbe-*' -ErrorAction SilentlyContinue) | ForEach-Object -Process { $report.Add("task $($_.TaskName) still exists") } + $report + } + Write-Step ('cleanup: ' + $(if (@($leftOver).Count -eq 0) { 'nothing left on the machine' } else { @($leftOver) -join '; ' })) + Remove-PSSession -Session $session -ErrorAction SilentlyContinue + } +} + +Write-Step "probe-$Label-DONE"