From 02a08ab2cf3a1573ce4e66cbffd62adc7aeaa007 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Sat, 10 Oct 2026 02:18:35 +0000 Subject: [PATCH] test(lab): add the probe of the authorization managers of Get-NTFSEffectiveAccess Probe-EffectiveAccess.ps1 runs Invoke-EffectiveAccessProbe.ps1 on one machine of the operating-system matrix under up to four tokens and copies the output back: the lab account in a scheduled task at the highest run level, the same account with the token of a basic user (SAFER level Normal User), a local standard user, and a standard user of the domain. The standard users are created for the run with a random password that exists only in memory, get the batch logon right through Performance Log Users, and are removed again with their profiles and group memberships; the names carry a time stamp, because Windows keeps the SID of a deleted account for its name for a while. For the account of the token and for well-known SIDs and the accounts of the domain, the probe asks the cmdlet for the default server name, localhost, an empty name, the names of this computer, and other computers, and writes the result, the warnings, and the native error with the failing method. It showed that the remote interface of the authorization manager of a computer in a domain refuses every user who isn't an administrator, which is the defect that the previous commit fixes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- .../Invoke-EffectiveAccessProbe.ps1 | 93 ++++++ .../Lab/Acceptance/Probe-EffectiveAccess.ps1 | 268 ++++++++++++++++++ 2 files changed, 361 insertions(+) create mode 100644 Tests/Lab/Acceptance/Invoke-EffectiveAccessProbe.ps1 create mode 100644 Tests/Lab/Acceptance/Probe-EffectiveAccess.ps1 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"