Remove Stale User Profiles (Package)
This Tanium package deletes Windows user profiles that nobody has signed in to for a set number of days. A user profile is the folder C:\Users\<name> and its entry in the registry.
Old profiles stay on a computer when a person leaves, or when the computer goes to a new person and is not reimaged. Vulnerability scanners report the old software that is in these profiles. When you delete the profile, you remove those files.
The package has an Audit mode. Audit mode lists the profiles that the package would delete. It changes nothing.
The package does not restart the endpoint.
Test status
The package was run through Tanium on one Windows 11 endpoint. Audit mode and Delete mode completed with exit code 0. Delete mode removed one domain profile and one local profile that was named in Users to delete.
These cases were not tested: a delete that stops before it completes (for example, a locked file), the Users to keep list, an empty Users to delete value, a very large profile, and Microsoft Entra ID accounts. Deploy to one endpoint first and read the action log.
You cannot restore a deleted profile
The package deletes all the files in the profile. Files that are only on that computer are gone. Examples are files on the Desktop or in Documents that are not synced, Outlook data files, and browser data.
Before you use Delete mode, make sure that the person does not use the computer. Make sure that your organization does not have to keep the data.
What the package does
- Makes sure that the endpoint is a workstation. On a server or a domain controller, the package stops. It uses exit code 3.
- Finds each user profile on the endpoint.
- Finds the date when each profile was last used.
- Decides to keep or delete each profile. The action log shows the reason for each decision.
- In Delete mode, checks each profile again immediately before the delete. If a person signed in after the first check, the package keeps the profile.
- Deletes the profile through Windows. Windows removes the folder and the registry entry together.
- Makes sure that the folder and the registry entry are gone.
- Writes the free space before and after to the action log.
How the package finds the last-used date
The package uses the first date that it finds, in this order:
| Order | Source | Notes |
|---|---|---|
| 1 | Sign-in time and sign-out time | Windows stores these times in the registry for each profile. The package uses the newer of the two. |
| 2 | Windows upgrade date | The package uses this date only when the profile has no sign-in time and no sign-out time. |
| 3 | Last-use time from Windows | The package uses this date only when the first two are missing. This date can only cause the package to keep a profile. |
Sign-in time and sign-out time. The values are in this registry key:
The value names are LocalProfileLoadTimeHigh, LocalProfileLoadTimeLow, LocalProfileUnloadTimeHigh, and LocalProfileUnloadTimeLow. The Windows policy Delete user profiles older than a specified number of days on system restart uses the same values.
Windows upgrade date. The same registry key can have a value with the name Migrated. A profile that has this value, but no sign-in time, was not used after that date. Thus, that date is the latest date that the profile was used.
Note
Microsoft does not document the Migrated value. On the test endpoint, two profiles had no sign-in time. The two profiles had the same Migrated date, less than one second apart. This agrees with a Windows upgrade that moved the two profiles in one step.
Last-use time from Windows. Antivirus scans and updates change this date. An old profile can show the date of today. The package logs this source as Windows last-use time (less exact).
The package does not use the date of the file NTUSER.DAT. Windows updates and antivirus scans change that date also.
Profiles that the package always keeps
The package keeps a profile when one of these is true:
- The profile is a system profile, such as SYSTEM, LocalService, or NetworkService.
- The account is a built-in account, such as Administrator or Guest.
- A person is signed in to the profile, or a service uses the account.
- The profile is for the last person who signed in to the endpoint.
- The folder name is in the Users to keep list.
- The account is a local account, and its folder name is not in Users to delete.
- The folder is not directly in the profiles folder (
C:\Users). - The package cannot find a last-used date.
- The profile was used more recently than Days not used.
Requirements
- Windows 10 or Windows 11 workstations
- Windows PowerShell 5.1
- The Tanium Client
Create the package
- In the Tanium Console, go to Administration > Content > Packages.
- Click Create Package.
- Fill in the fields with the values in the table below.
- In the Files section, add the script file.
- In the Parameters section, add the four parameters in the second table below. Add them in the order
$1to$4. - For
$3, do not enter a default value. - Click Save.
| Field | Value |
|---|---|
| Package Name | Remove Stale User Profiles |
| Display Name | Remove Stale User Profiles |
| Description | Deletes Windows user profiles (C:\Users\name) that nobody has signed in to for a set number of days. You must name the users to delete, or type ALL. You can also list users that must never be deleted. Audit mode lists what would be deleted and changes nothing. Keeps profiles in use, the last signed-in user, built-in and system accounts. Workstations only. |
| Content Set | Your custom content set. Use a content set that only administrators can deploy. |
| Command | cmd.exe /d /c powershell.exe -ExecutionPolicy Bypass -WindowStyle Hidden -NonInteractive -NoProfile -File Remove-StaleUserProfiles.ps1 "$1" "$2" "$3" "$4" |
| Command Timeout | 60 minutes |
| Download Timeout | 10 minutes |
| Ignore Action Lock | Clear (not selected) |
| Launch this package command in a process group | Selected |
| Files | Remove-StaleUserProfiles.ps1 |
Parameters
| Key | Type | Label | Values | Default |
|---|---|---|---|---|
$1 |
Drop Down List | Mode | Audit, Delete | Audit |
$2 |
Numeric | Days not used | 30 or more | 90 |
$3 |
Text Input | Users to delete | Folder names, or ALL |
None. The operator must enter a value. |
$4 |
Text Input | Users to keep | Folder names, or NONE |
NONE |
Enter this help text for each parameter:
| Key | Help text |
|---|---|
$1 |
Audit lists what would be deleted and changes nothing. Delete removes the profiles. |
$2 |
A profile is only deleted if nobody has signed in to it for this many days. 30 or more. |
$3 |
Required. Folder names from C:\Users, separated by commas, for example jdoe,asmith. Type ALL for every profile that passes the checks. |
$4 |
Only needed when Users to delete is ALL. Folder names from C:\Users that must never be deleted, separated by commas. Wildcards work, for example svc*,adm*. Otherwise leave NONE. |
Rules for the parameters
- Days not used must be 30 or more. A lower value stops the package. Nothing is changed.
- Users to delete is necessary. An empty value stops the package. Nothing is changed.
- A name is the folder name in
C:\Users. ForC:\Users\jdoe, the name isjdoe. The folder name can be different from the account name, for examplejdoe.EXAMPLE. - Users to delete does not accept wildcards (
*and?). ALLselects each profile that passes the checks. A local account is not included inALL. To delete the profile of a local account, enter its folder name.- Users to keep accepts wildcards, for example
svc*. - A name in Users to keep is always kept. This is true when the name is also in Users to delete.
- When you enter names in Users to delete, the package can delete only those profiles. Leave Users to keep as
NONE. - Uppercase and lowercase letters are the same.
allandALLgive the same result.
Deploy the package
Deploy the package two times. Use Audit mode first. Then use Delete mode.
Run 1: Audit
- In the Tanium Console, go to Interact.
-
In the question bar, type this question. Replace
ENDPOINT-01with the name of your endpoint. -
Press Enter.
- Select the endpoints.
- Click Deploy Action.
- In the Deployment Package field, type
Remove Stale User Profiles. Select the package. - Set Mode to
Audit. - Set Days not used. The default is
90. - In Users to delete, enter the folder names from your findings, for example
jdoe,asmith. - Leave Users to keep as
NONE. - Click Show preview to continue.
- Make sure that the list shows the correct endpoints.
- Click Deploy Action.
- Read the action log. Refer to Read the results.
- Make sure that each
WOULD DELETEline shows a profile that you want to delete.
Run 2: Delete
- Deploy the package again to the same endpoints.
- Set Mode to
Delete. - Enter the same values for the other parameters.
- Click Show preview to continue.
- Click Deploy Action.
- Read the action log.
- Make sure that each profile shows a
DELETEDline.
Tip
Enter folder names in Users to delete when you know the names. Use ALL only when you want to remove each old profile on the endpoint.
Exit codes
| Code | Meaning |
|---|---|
| 0 | The package completed. No delete failed. |
| 1 | One or more profiles were not fully deleted. Read the action log. |
| 2 | A parameter was incorrect. Nothing was changed. |
| 3 | The endpoint is a server or a domain controller. Nothing was changed. |
| 4 | An error occurred that the package did not expect. Read the action log. |
In Action History, an exit code that is not 0 can show as Failed. For codes 2 and 3, the package stopped before it changed anything.
Read the results
The action log shows one line for each profile. The line shows the folder, the last-used date, the source of that date, and the reason for the decision.
To read the action log from the Tanium Console:
- Go to Interact.
-
Type this question. Replace
<action ID>with the ID of your action. -
Press Enter.
Each line starts with one of these words:
| Word | Meaning |
|---|---|
KEEP |
The package did not change the profile. The line shows the reason. |
WOULD DELETE |
Audit mode only. Delete mode would delete this profile. |
DELETING |
Delete mode. The delete started. |
DELETED |
The folder and the registry entry are gone. |
FAILED |
The delete did not complete. The line shows what stayed. |
NOT FOUND |
A name in Users to delete is not a profile on this endpoint. |
The package also writes the same lines to this file on the endpoint:
Example log: Audit
[INFO] ===== Remove-StaleUserProfiles 1.1.0 started on ENDPOINT-01 =====
[INFO] Settings: mode=Audit; days not used=90; users to delete=ALL; users to keep=NONE
[INFO] Profiles folder: C:\Users. User profiles found: 4.
[INFO] KEEP jdoe | folder: C:\Users\jdoe | last used: 2026-11-12 (sign-in/sign-out time stored by Windows) | why: in use right now
[INFO] WOULD DELETE asmith | folder: C:\Users\asmith | last used: 2026-06-03 (sign-in/sign-out time stored by Windows) | why: not used for 162 day(s), limit is 90
[INFO] KEEP Administrator | folder: C:\Users\Administrator | last used: 2026-10-17 (sign-in/sign-out time stored by Windows) | why: built-in account
[INFO] KEEP labadmin | folder: C:\Users\labadmin | last used: 2026-07-08 (date of the last Windows upgrade, not signed in to since) | why: local account (only deleted when named in the list of users to delete)
[INFO] Summary (Audit, nothing changed): would delete=1; keep=3; free space=142.04 GB
[INFO] ===== Finished with exit code 0 =====
Example log: Delete
[INFO] ===== Remove-StaleUserProfiles 1.1.0 started on ENDPOINT-01 =====
[INFO] Settings: mode=Delete; days not used=90; users to delete=asmith; users to keep=NONE
[INFO] Profiles folder: C:\Users. User profiles found: 4.
[INFO] KEEP jdoe | folder: C:\Users\jdoe | last used: 2026-11-12 (sign-in/sign-out time stored by Windows) | why: not on the list of users to delete
[INFO] DELETING asmith | folder: C:\Users\asmith | last used: 2026-06-03 (sign-in/sign-out time stored by Windows) | why: not used for 162 day(s), limit is 90
[INFO] DELETED asmith | folder and registry entry are gone | took 31 second(s)
[INFO] KEEP Administrator | folder: C:\Users\Administrator | last used: 2026-10-17 (sign-in/sign-out time stored by Windows) | why: built-in account
[INFO] KEEP labadmin | folder: C:\Users\labadmin | last used: 2026-07-08 (date of the last Windows upgrade, not signed in to since) | why: not on the list of users to delete
[INFO] Summary (Delete): deleted=1; failed=0; kept=3; free space before=142.04 GB; after=143.42 GB
[INFO] ===== Finished with exit code 0 =====
Troubleshooting
| Log message | Cause | Action |
|---|---|---|
Days not used must be 30 or more |
The value of Days not used is lower than 30. | Deploy again with a value of 30 or more. |
Users to delete is required |
Users to delete was empty. | Deploy again. Enter folder names or ALL. |
Wildcards are not allowed in the list of users to delete |
A name in Users to delete has * or ?. |
Deploy again. Enter each folder name in full. |
This computer is a server or domain controller |
The package works only on workstations. | No action is necessary. Do not target servers. |
NOT FOUND |
No profile on the endpoint has this folder name. | Compare the name with the folder name in C:\Users. The folder name can be different from the account name. |
why: in use right now |
A person is signed in to the profile, or a service uses the account. | Deploy again after the person signs out, or after the endpoint restarts. |
why: belongs to the last person who signed in to this computer |
Windows shows this account as the last sign-in. | The package always keeps this profile. Deploy again after a different person signs in. |
Windows last-use time (less exact) with a recent date |
The profile has no sign-in time and no Windows upgrade date. The package cannot find the date when the profile was last used. | The package keeps this profile. Remove the profile by a different method. |
FAILED with folder (or part of it) is still there |
A file in the profile is in use. This case was not tested. | Restart the endpoint. Then deploy again. |
| The command timeout occurs | The profile is very large, or it has many small files. | Increase the command timeout. Then deploy again. |
Things to know
- Parameters arrive URL-encoded. Tanium passes
jdoe,asmithto the script asjdoe%2Casmith. The script decodes each value before it uses the value. - The log file stays on the endpoint. Tanium removes the action folder after some time. The log file in
C:\ProgramData\RemoveStaleUserProfilesstays. When the file is larger than 1 MB, the script keeps one old copy and starts a new file. - The 30-day minimum is in the script. The value is
$MinimumDays, near the top of the script. - Each Windows upgrade resets the date for unused profiles. After an upgrade, a profile that nobody uses shows the upgrade date as its last-used date. The package can delete the profile when that date is older than Days not used.
Script
Remove-StaleUserProfiles.ps1
<#
.SYNOPSIS
Remove-StaleUserProfiles.ps1
Deletes Windows user profiles (C:\Users\<name>) that nobody has signed in to
for a set number of days. Built to run as a Tanium package (runs as SYSTEM).
.DESCRIPTION
For every user profile on the computer, the script works out when the profile
was last signed in to or signed out of, then keeps or deletes it.
How "last used" is worked out:
1. Sign-in and sign-out times that Windows stores in the registry under
HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\<SID>
(LocalProfileLoadTimeHigh/Low and LocalProfileUnloadTimeHigh/Low).
The newer of the two is used.
2. If those values are missing: the "Migrated" date in the same registry
key. Windows writes it when it carries a profile over during a Windows
upgrade (for example 23H2 to 25H2). A profile that has that date but no
sign-in or sign-out time has not been signed in to since the upgrade,
so the upgrade date is the latest it can have been used.
3. Only if that is missing too: the LastUseTime that Windows reports for
the profile (Win32_UserProfile). Antivirus scans and updates change this
value, so it often shows today. It can only cause a profile to be kept.
The date on NTUSER.DAT is NOT used. Windows updates and antivirus scans change
it, so it makes old profiles look recently used.
A profile is always kept when any of these is true:
- It is a system profile (SYSTEM, LocalService, NetworkService).
- It is a built-in account (Administrator, Guest, and so on).
- Someone is signed in to it right now (or a service is running as it).
- It belongs to the last person who signed in to the computer.
- Its folder name is on the "users to keep" list.
- It is a local account and was not named in "users to delete".
- Its folder is not directly inside the Windows profiles folder (C:\Users).
- The script cannot tell when it was last used.
- It was used more recently than the number of days given.
The script refuses to run on servers and domain controllers.
.PARAMETER (positional, passed by Tanium as "$1" "$2" "$3" "$4")
$1 Mode Audit = list what would be deleted, change nothing (default)
Delete = delete the profiles
$2 Days not used Whole number, 30 or more. Default 90.
$3 Users to delete Required, there is no default. Either:
a comma-separated list of profile folder names, for
example jsmith,adoe (the name after C:\Users\), no
wildcards, or
ALL = every profile that passes the checks.
If it is left empty the script stops and changes nothing.
$4 Users to keep Optional. NONE (default), or a comma-separated list of
profile folder names that must never be deleted.
Wildcards are allowed, for example svc*,adm*
This list always wins: a name here is kept even if it is
also in "users to delete" or matches ALL.
.NOTES
Exit codes:
0 Finished. Nothing failed.
1 At least one profile could not be fully deleted.
2 A parameter was wrong. Nothing was changed.
3 The computer is a server or domain controller. Nothing was changed.
4 Unexpected error. See the log.
Log (kept after the Tanium action folder is cleaned up):
C:\ProgramData\RemoveStaleUserProfiles\Remove-StaleUserProfiles.log
The same lines are written to the Tanium action log.
Works with Windows PowerShell 5.1.
#>
$ErrorActionPreference = 'Stop'
$ScriptVersion = '1.1.0'
$MinimumDays = 30 # Lowest value accepted for "days not used". Guards against typos such as 0 or 9.
$LogFolder = Join-Path $env:ProgramData 'RemoveStaleUserProfiles'
$LogFile = Join-Path $LogFolder 'Remove-StaleUserProfiles.log'
$RawArgs = @($args)
# Folder names that are never touched, whatever else is true.
$NeverTouchNames = @('Public', 'Default', 'Default User', 'All Users', 'Administrator')
# ----------------------------------------------------------------------------
# Logging: every line goes to the Tanium action log (standard output) and to a
# log file that stays on the computer.
# ----------------------------------------------------------------------------
function Write-Log {
param([string]$Message, [string]$Level = 'INFO')
$line = '{0} [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message
Write-Host $line
try {
if (-not (Test-Path -LiteralPath $LogFolder)) {
New-Item -Path $LogFolder -ItemType Directory -Force | Out-Null
}
Add-Content -LiteralPath $LogFile -Value $line -Encoding UTF8
} catch {
# A logging problem must never stop the work.
}
}
function Start-LogFile {
# Keep the log from growing forever: once it passes 1 MB, keep one old copy.
try {
if (Test-Path -LiteralPath $LogFile) {
if ((Get-Item -LiteralPath $LogFile).Length -gt 1MB) {
Move-Item -LiteralPath $LogFile -Destination ($LogFile + '.old') -Force
}
}
} catch { }
}
# ----------------------------------------------------------------------------
# Parameters
# ----------------------------------------------------------------------------
function Get-PackageParameter {
# Tanium can pass parameter values percent-encoded (for example %2C for a comma).
# Decoding a value that is not encoded leaves it unchanged.
param([int]$Index, [string]$Default)
if ($RawArgs.Count -gt $Index -and $null -ne $RawArgs[$Index]) {
$value = [string]$RawArgs[$Index]
try { $value = [System.Uri]::UnescapeDataString($value) } catch { }
$value = $value.Trim().Trim('"').Trim("'").Trim()
if ($value -ne '') { return $value }
}
return $Default
}
function ConvertTo-NameList {
# Turns "jsmith, C:\Users\adoe ;bob" into jsmith, adoe, bob
param([string]$Text)
$names = @()
foreach ($part in ($Text -split '[,;]')) {
$name = $part.Trim().Trim('"').Trim("'").Trim().TrimEnd('\').TrimEnd('/')
if ($name -eq '') { continue }
$cut = [Math]::Max($name.LastIndexOf('\'), $name.LastIndexOf('/'))
if ($cut -ge 0) { $name = $name.Substring($cut + 1) }
if ($name -ne '') { $names += $name }
}
return , $names
}
# ----------------------------------------------------------------------------
# Working out when a profile was last used
# ----------------------------------------------------------------------------
function Convert-RegFileTime {
# Windows stores each time as two registry numbers (high and low half).
# Returns a UTC date, or $null when the value is missing or makes no sense.
param($High, $Low)
if ($null -eq $High -or $null -eq $Low) { return $null }
try {
$h = ([int64]$High) -band 4294967295
$l = ([int64]$Low) -band 4294967295
$fileTime = ($h -shl 32) -bor $l
if ($fileTime -le 0) { return $null }
$date = [DateTime]::FromFileTimeUtc($fileTime)
if ($date.Year -lt 2000) { return $null }
return $date
} catch {
return $null
}
}
function Convert-RegBinaryFileTime {
# The "Migrated" value is one 8-byte date. Returns a UTC date, or $null.
param($Bytes)
if ($null -eq $Bytes) { return $null }
try {
$raw = [byte[]]$Bytes
if ($raw.Length -lt 8) { return $null }
$fileTime = [BitConverter]::ToInt64($raw, 0)
if ($fileTime -le 0) { return $null }
$date = [DateTime]::FromFileTimeUtc($fileTime)
if ($date.Year -lt 2000) { return $null }
return $date
} catch {
return $null
}
}
function Get-ProfileLastUsed {
# Returns @{ Utc = <date or $null>; Source = <text> }
param($LoadUtc, $UnloadUtc, $MigratedUtc, $WmiLastUseUtc)
$best = $null
foreach ($candidate in @($LoadUtc, $UnloadUtc)) {
if ($null -ne $candidate -and ($null -eq $best -or $candidate -gt $best)) { $best = $candidate }
}
if ($null -ne $best) {
return @{ Utc = $best; Source = 'sign-in/sign-out time stored by Windows' }
}
if ($null -ne $MigratedUtc) {
return @{ Utc = $MigratedUtc; Source = 'date of the last Windows upgrade, not signed in to since' }
}
if ($null -ne $WmiLastUseUtc) {
return @{ Utc = $WmiLastUseUtc; Source = 'Windows last-use time (less exact)' }
}
return @{ Utc = $null; Source = 'unknown' }
}
# ----------------------------------------------------------------------------
# The keep-or-delete decision for one profile. No side effects.
# ----------------------------------------------------------------------------
function Get-ProfileDecision {
param(
[string]$Name,
[string]$Sid,
[string]$Path,
[bool]$Loaded,
[bool]$IsLocalAccount,
[bool]$IsLastSignedIn,
$LastUsedUtc,
[datetime]$NowUtc,
[int]$InactiveDays,
[string[]]$DeleteList,
[string[]]$KeepList,
[string]$ProfilesDir,
[string[]]$NeverTouch
)
$keep = { param($why) return @{ Action = 'Keep'; Reason = $why } }
if ([string]::IsNullOrWhiteSpace($Path) -or [string]::IsNullOrWhiteSpace($Name)) {
return (& $keep 'Windows has no folder recorded for it')
}
if ($Sid -notlike 'S-1-5-21-*' -and $Sid -notlike 'S-1-12-1-*') {
return (& $keep 'not a normal user account')
}
if ($Sid -like 'S-1-5-21-*') {
[int64]$lastPart = 0
if ([int64]::TryParse(($Sid -split '-')[-1], [ref]$lastPart) -and $lastPart -lt 1000) {
return (& $keep 'built-in account')
}
}
if ($NeverTouch -contains $Name) {
return (& $keep 'protected folder name')
}
$named = $false
if ($DeleteList.Count -gt 0) {
if ($DeleteList -notcontains $Name) {
return (& $keep 'not on the list of users to delete')
}
$named = $true
}
if ($Loaded) {
return (& $keep 'in use right now')
}
if ($IsLastSignedIn) {
return (& $keep 'belongs to the last person who signed in to this computer')
}
foreach ($pattern in $KeepList) {
if ($Name -like $pattern) {
return (& $keep ("on the list of users to keep ({0})" -f $pattern))
}
}
if ($IsLocalAccount -and -not $named) {
return (& $keep 'local account (only deleted when named in the list of users to delete)')
}
# The folder must sit directly inside the profiles folder, for example C:\Users\jsmith
$cleanPath = $Path.TrimEnd('\')
$cut = $cleanPath.LastIndexOf('\')
$parent = ''
if ($cut -gt 0) { $parent = $cleanPath.Substring(0, $cut) }
if ($parent -eq '' -or ($parent -ne $ProfilesDir.TrimEnd('\'))) {
return (& $keep ("folder is not directly inside {0}" -f $ProfilesDir))
}
if ($null -eq $LastUsedUtc) {
return (& $keep 'cannot tell when it was last used')
}
$idleDays = [int][Math]::Floor(($NowUtc - $LastUsedUtc).TotalDays)
if ($idleDays -lt 0) {
return (& $keep 'last-used date is in the future (check the computer clock)')
}
if ($idleDays -lt $InactiveDays) {
return (& $keep ("used {0} day(s) ago, limit is {1}" -f $idleDays, $InactiveDays))
}
return @{ Action = 'Delete'; Reason = ("not used for {0} day(s), limit is {1}" -f $idleDays, $InactiveDays) }
}
# ----------------------------------------------------------------------------
# Windows lookups (registry is always read in the 64-bit view)
# ----------------------------------------------------------------------------
function Open-LocalMachineKey {
param([string]$SubKey)
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
[Microsoft.Win32.RegistryHive]::LocalMachine,
[Microsoft.Win32.RegistryView]::Registry64)
return $base.OpenSubKey($SubKey)
}
function Get-LocalSidPrefix {
# Every local account on a computer shares the same start of its SID.
$sids = @()
try {
$sids = @(Get-CimInstance -ClassName Win32_UserAccount -Filter 'LocalAccount = True' | ForEach-Object { $_.SID })
} catch { }
if ($sids.Count -eq 0) {
try { $sids = @(Get-LocalUser | ForEach-Object { $_.SID.Value }) } catch { }
}
foreach ($sid in $sids) {
if ($sid -like 'S-1-5-21-*') {
return $sid.Substring(0, $sid.LastIndexOf('-'))
}
}
throw 'Could not read the list of local accounts, so local accounts cannot be told apart from others.'
}
function Get-FreeSpaceGB {
try {
$disk = Get-CimInstance -ClassName Win32_LogicalDisk -Filter ("DeviceID = '{0}'" -f $env:SystemDrive)
return [Math]::Round($disk.FreeSpace / 1GB, 2)
} catch {
return $null
}
}
# ----------------------------------------------------------------------------
# Deleting one profile
# ----------------------------------------------------------------------------
function Remove-OneProfile {
# Returns @{ Ok = $true/$false; Note = <text> }
param([string]$Sid, [string]$Path, [string]$ProfilesDir)
# Look again right before deleting, in case someone signed in since the check.
$fresh = Get-CimInstance -ClassName Win32_UserProfile -Filter ("SID = '{0}'" -f $Sid)
if ($null -eq $fresh) {
return @{ Ok = $false; Note = 'Windows no longer lists this profile' }
}
if ($fresh.Loaded) {
return @{ Ok = $false; Note = 'someone signed in to it after the check, left alone' }
}
$deleteError = $null
try {
# The Windows-supported way: removes the folder and the registry entry together.
Remove-CimInstance -InputObject $fresh -ErrorAction Stop
} catch {
$deleteError = $_.Exception.Message
}
$regKey = Open-LocalMachineKey ("SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\{0}" -f $Sid)
$regGone = ($null -eq $regKey)
if (-not $regGone) { $regKey.Close() }
$folderGone = -not (Test-Path -LiteralPath $Path)
if ($regGone -and -not $folderGone) {
# Windows removed the profile entry but left files behind. Clear the leftover folder.
$cleanPath = $Path.TrimEnd('\')
$parent = $cleanPath.Substring(0, $cleanPath.LastIndexOf('\'))
if ($parent -eq $ProfilesDir.TrimEnd('\')) {
try {
$rd = Start-Process -FilePath $env:ComSpec -ArgumentList ('/d /c rd /s /q "{0}"' -f $cleanPath) -Wait -PassThru -WindowStyle Hidden
} catch { }
$folderGone = -not (Test-Path -LiteralPath $Path)
}
}
if ($regGone -and $folderGone) {
return @{ Ok = $true; Note = 'folder and registry entry are gone' }
}
$detail = @()
if (-not $regGone) { $detail += 'registry entry is still there' }
if (-not $folderGone) { $detail += 'folder (or part of it) is still there' }
if ($deleteError) { $detail += ("Windows said: {0}" -f $deleteError) }
return @{ Ok = $false; Note = ($detail -join '; ') }
}
# ----------------------------------------------------------------------------
# Main
# ----------------------------------------------------------------------------
function Invoke-Main {
Start-LogFile
$mode = Get-PackageParameter -Index 0 -Default 'Audit'
$daysText = Get-PackageParameter -Index 1 -Default '90'
$deleteText = Get-PackageParameter -Index 2 -Default ''
$keepText = Get-PackageParameter -Index 3 -Default 'NONE'
Write-Log ("===== Remove-StaleUserProfiles {0} started on {1} =====" -f $ScriptVersion, $env:COMPUTERNAME)
Write-Log ("Settings: mode={0}; days not used={1}; users to delete={2}; users to keep={3}" -f $mode, $daysText, $deleteText, $keepText)
# --- Check the settings ---
if ($mode -notin @('Audit', 'Delete')) {
Write-Log ("Mode must be Audit or Delete. Got: {0}. Nothing was changed." -f $mode) 'ERROR'
return 2
}
$inactiveDays = 0
if (-not [int]::TryParse($daysText, [ref]$inactiveDays)) {
Write-Log ("Days not used must be a whole number. Got: {0}. Nothing was changed." -f $daysText) 'ERROR'
return 2
}
if ($inactiveDays -lt $MinimumDays) {
Write-Log ("Days not used must be {0} or more. Got: {1}. Nothing was changed." -f $MinimumDays, $inactiveDays) 'ERROR'
return 2
}
if ($deleteText -eq '') {
Write-Log 'Users to delete is required. Give profile folder names (for example jsmith,adoe) or ALL. Nothing was changed.' 'ERROR'
return 2
}
$deleteList = @()
if ($deleteText -ne 'ALL') {
$deleteList = ConvertTo-NameList $deleteText
if ($deleteList.Count -eq 0) {
Write-Log 'Users to delete must be ALL or a list of profile folder names. Nothing was changed.' 'ERROR'
return 2
}
foreach ($name in $deleteList) {
if ($name -match '[\*\?]') {
Write-Log ("Wildcards are not allowed in the list of users to delete. Got: {0}. Nothing was changed." -f $name) 'ERROR'
return 2
}
}
}
$keepList = @()
if ($keepText -ne 'NONE') { $keepList = ConvertTo-NameList $keepText }
# --- Workstations only ---
$os = Get-CimInstance -ClassName Win32_OperatingSystem
if ($os.ProductType -ne 1) {
Write-Log ("This computer is a server or domain controller ({0}). The package is for workstations only. Nothing was changed." -f $os.Caption) 'ERROR'
return 3
}
# --- Collect facts ---
$profileListKey = Open-LocalMachineKey 'SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList'
if ($null -eq $profileListKey) { throw 'Could not open the ProfileList registry key.' }
$profilesDir = [string]$profileListKey.GetValue('ProfilesDirectory')
if ([string]::IsNullOrWhiteSpace($profilesDir)) { $profilesDir = Join-Path $env:SystemDrive 'Users' }
$profilesDir = $profilesDir.TrimEnd('\')
$lastSignedInSids = @()
$logonUiKey = Open-LocalMachineKey 'SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI'
if ($null -ne $logonUiKey) {
foreach ($valueName in @('LastLoggedOnUserSID', 'SelectedUserSID')) {
$value = [string]$logonUiKey.GetValue($valueName)
if ($value) { $lastSignedInSids += $value }
}
$logonUiKey.Close()
}
$localSidPrefix = Get-LocalSidPrefix
$nowUtc = (Get-Date).ToUniversalTime()
$freeBefore = Get-FreeSpaceGB
$profiles = @(Get-CimInstance -ClassName Win32_UserProfile -Filter 'Special = False')
Write-Log ("Profiles folder: {0}. User profiles found: {1}." -f $profilesDir, $profiles.Count)
$countKept = 0; $countWould = 0; $countDeleted = 0; $countFailed = 0
$foundNames = @()
foreach ($p in $profiles) {
$sid = [string]$p.SID
$path = [string]$p.LocalPath
$name = ''
if ($path) {
$trimmed = $path.TrimEnd('\')
$name = $trimmed.Substring($trimmed.LastIndexOf('\') + 1)
}
$foundNames += $name
# Sign-in / sign-out times from the registry
$loadUtc = $null; $unloadUtc = $null; $migratedUtc = $null
$sidKey = $profileListKey.OpenSubKey($sid)
if ($null -ne $sidKey) {
$loadUtc = Convert-RegFileTime $sidKey.GetValue('LocalProfileLoadTimeHigh') $sidKey.GetValue('LocalProfileLoadTimeLow')
$unloadUtc = Convert-RegFileTime $sidKey.GetValue('LocalProfileUnloadTimeHigh') $sidKey.GetValue('LocalProfileUnloadTimeLow')
$migratedUtc = Convert-RegBinaryFileTime $sidKey.GetValue('Migrated')
$sidKey.Close()
}
$wmiUtc = $null
if ($null -ne $p.LastUseTime) { $wmiUtc = ([datetime]$p.LastUseTime).ToUniversalTime() }
$lastUsed = Get-ProfileLastUsed -LoadUtc $loadUtc -UnloadUtc $unloadUtc -MigratedUtc $migratedUtc -WmiLastUseUtc $wmiUtc
$decision = Get-ProfileDecision -Name $name -Sid $sid -Path $path -Loaded ([bool]$p.Loaded) `
-IsLocalAccount ($sid -like ($localSidPrefix + '-*')) `
-IsLastSignedIn ($lastSignedInSids -contains $sid) `
-LastUsedUtc $lastUsed.Utc -NowUtc $nowUtc -InactiveDays $inactiveDays `
-DeleteList $deleteList -KeepList $keepList -ProfilesDir $profilesDir -NeverTouch $NeverTouchNames
$lastUsedText = 'unknown'
if ($null -ne $lastUsed.Utc) {
$lastUsedText = '{0} ({1})' -f $lastUsed.Utc.ToLocalTime().ToString('yyyy-MM-dd'), $lastUsed.Source
}
$about = '{0} | folder: {1} | last used: {2}' -f $name, $path, $lastUsedText
if ($decision.Action -eq 'Keep') {
$countKept++
Write-Log ("KEEP {0} | why: {1}" -f $about, $decision.Reason)
continue
}
if ($mode -eq 'Audit') {
$countWould++
Write-Log ("WOULD DELETE {0} | why: {1}" -f $about, $decision.Reason)
continue
}
Write-Log ("DELETING {0} | why: {1}" -f $about, $decision.Reason)
$timer = [System.Diagnostics.Stopwatch]::StartNew()
$result = Remove-OneProfile -Sid $sid -Path $path -ProfilesDir $profilesDir
$timer.Stop()
if ($result.Ok) {
$countDeleted++
Write-Log ("DELETED {0} | {1} | took {2} second(s)" -f $name, $result.Note, [int]$timer.Elapsed.TotalSeconds)
} else {
$countFailed++
Write-Log ("FAILED {0} | {1}" -f $name, $result.Note) 'ERROR'
}
}
$profileListKey.Close()
# Tell the operator about names that were asked for but are not on this computer.
foreach ($wanted in $deleteList) {
if ($foundNames -notcontains $wanted) {
Write-Log ("NOT FOUND {0} | no user profile with this folder name on this computer" -f $wanted) 'WARN'
}
}
$freeAfter = Get-FreeSpaceGB
if ($mode -eq 'Audit') {
Write-Log ("Summary (Audit, nothing changed): would delete={0}; keep={1}; free space={2} GB" -f $countWould, $countKept, $freeAfter)
} else {
Write-Log ("Summary (Delete): deleted={0}; failed={1}; kept={2}; free space before={3} GB; after={4} GB" -f $countDeleted, $countFailed, $countKept, $freeBefore, $freeAfter)
}
if ($countFailed -gt 0) { return 1 }
return 0
}
$exitCode = 4
try {
# Take the last value only, so a stray output line can never replace the exit code.
$exitCode = [int](@(Invoke-Main)[-1])
} catch {
Write-Log ("Unexpected error: {0}" -f $_.Exception.Message) 'ERROR'
$exitCode = 4
}
Write-Log ("===== Finished with exit code {0} =====" -f $exitCode)
exit $exitCode