When you login to a server it creates a profile, the trouble is there is no maintenance for this in Windows, so long after you have left your profile will still be on the server, this article addresses this by removing the profile and all the associated registry keys as well.
I wonder can this be script, well, obviously this can be scripted, however, we have to make sure we do it correctly, but lets get the checks for validity and then the process for deletion mapped out first.
Script Process Flow
- NTUSER.DAT last write time in C:\Users[Username]\NTUSER.DAT
- Recent Items folder activity in C:\Users[Username]\AppData\Roaming\Microsoft\Windows\Recent
- Event Log entries for logons (Security Event ID 4624)
Activity in these profile folders:
- AppData\Local\Microsoft\Windows\UsrClass.dat
- AppData\Local\Microsoft\Windows\Explorer
- AppData\Local\Microsoft\Windows\FileHistory\Data
- AppData\Local\Temp
- AppData\Local\Microsoft\Windows\History
- AppData\Local\Microsoft\Windows\INetCache
Profile Validation:
- Confirms profile is not in excluded users list
- Verifies it's not a special system profile
- Checks if profile path exists in C:\Users
- Validates SID exists for the profile
What is removed?
Profile Directory:
Profile Directory:
- Complete user profile folder at C:\Users[Username]
- All contents and subdirectories
Registry Locations (using user's specific SID):
- HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\[SID]
- HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileGuid\[SID]
- HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles\[SID]
- HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\UserAssist\[SID]
- HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp\UserProfiles\[SID]
- HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers\[SID]
- HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders\[SID]
- HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\KnownFolders\[SID]
GUID registry Searches:
- Scans these parent paths for the user's SID:
- HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList
- HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileGuid
Command to run the script
If you wish to run the script normally then it will apply to add profiles on the device, so the customisation is done with the command you run for example:
Test run (no deletions)
.\ProfileCleanup.ps1 -WhatIf
Run with email reporting
.\ProfileCleanup.ps1 -SendEmail -EmailTo "leecroucher.cloud" -EmailFrom "profile.cleanup@croucher.cloud" -SmtpServer "smtp.bear.local"
Run with custom exclusions and email
.\ProfileCleanup.ps1 -ExcludedUsers @("Administrator", "Public", "<userA>", "<userA>") -SendEmail -EmailTo "lee@croucher.cloud" -EmailFrom "profile.cleanup@croucher.cloud" -SmtpServer "smtp.bear.local"
Script : ProfileCleanup.ps1
<#
.SYNOPSIS
User Profile Cleanup Script with Registry Cleanup and Email Reporting
.DESCRIPTION
Automatically removes inactive user profiles and their registry entries on machines based on 30-day inactivity.
.PARAMETER ExcludedUsers
Array of usernames to exclude from cleanup
.PARAMETER WhatIf
Run in simulation mode without making changes
.PARAMETER SendEmail
Enable email reporting
.PARAMETER EmailTo
Recipient email address
.PARAMETER EmailFrom
Sender email address
.PARAMETER SmtpServer
SMTP server address
#>
param(
[string[]]$ExcludedUsers = @("Administrator", "Public"),
[switch]$WhatIf = $false,
[switch]$SendEmail = $false,
[string]$EmailTo = "",
[string]$EmailFrom = "",
[string]$SmtpServer = ""
)
# Email parameter validation
if ($SendEmail) {
if ([string]::IsNullOrEmpty($EmailTo) -or [string]::IsNullOrEmpty($EmailFrom) -or [string]::IsNullOrEmpty($SmtpServer)) {
Write-Error "Email parameters required when using -SendEmail. Please provide -EmailTo, -EmailFrom, and -SmtpServer"
exit
}
}
# Check OS Version - Modified for Server 2016 compatibility
$OSInfo = Get-WmiObject -Class Win32_OperatingSystem
$OSVersion = [System.Version]$OSInfo.Version
$MinVersion = [System.Version]"10.0.14393" # Server 2016 base version
if ($OSVersion -lt $MinVersion) {
Write-Warning "This script requires Windows Server 2016 or later. Current OS: $($OSInfo.Caption)"
exit
}
# Initialize variables
$ComputerName = $env:COMPUTERNAME
$Date = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$CutoffDate = (Get-Date).AddDays(-30)
$LogPath = "$env:TEMP\ProfileCleanup.log"
$ReportPath = "$env:TEMP\ProfileCleanup_$($ComputerName)_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv"
$DetailedReportPath = "$env:TEMP\ProfileCleanup_Detailed_$($ComputerName)_$(Get-Date -Format 'yyyyMMdd_HHmmss').txt"
function Write-Log {
param($Message)
$LogMessage = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'): $Message"
Add-Content -Path $LogPath -Value $LogMessage
Add-Content -Path $DetailedReportPath -Value $LogMessage
Write-Host $Message
}
function Get-UserSID {
param (
[string]$Username,
[string]$ProfilePath
)
Write-Log "Attempting to get SID for user: $Username"
try {
# Method 1: Try getting SID from profile path
$profileList = Get-WmiObject -Class Win32_UserProfile |
Where-Object { $_.LocalPath -eq $ProfilePath }
if ($profileList.SID) {
Write-Log "Found SID using WMI: $($profileList.SID)"
return $profileList.SID
}
# Method 2: Try getting SID from ProfileList registry
$profileListPath = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList"
Get-ChildItem $profileListPath | ForEach-Object {
$profileItem = Get-ItemProperty $_.PSPath
if ($profileItem.ProfileImagePath -eq $ProfilePath) {
Write-Log "Found SID using Registry: $($_.PSChildName)"
return $_.PSChildName
}
}
# Method 3: Try using Windows API
Add-Type -AssemblyName System.DirectoryServices.AccountManagement
$ctx = New-Object System.DirectoryServices.AccountManagement.PrincipalContext('Machine')
$user = [System.DirectoryServices.AccountManagement.UserPrincipal]::FindByIdentity($ctx, $Username)
if ($user -and $user.Sid) {
Write-Log "Found SID using Windows API: $($user.Sid.Value)"
return $user.Sid.Value
}
}
catch {
Write-Log "Error getting SID for $Username : $_"
}
Write-Log "Could not find SID for $Username"
return $null
}
function Get-ProfileLastActivity {
param (
[string]$ProfilePath,
[string]$Username
)
$activityDates = @()
Write-Log "Checking activity for profile: $Username"
try {
# Method 1: Check Event Log for user logons (most reliable)
try {
$startTime = (Get-Date).AddDays(-30)
$logonEvents = Get-EventLog -LogName Security -After $startTime -InstanceId 4624 -ErrorAction Stop |
Where-Object { $_.ReplacementStrings[5] -eq $Username }
if ($logonEvents) {
$lastLogon = ($logonEvents | Measure-Object TimeGenerated -Maximum).Maximum
$activityDates += $lastLogon
Write-Log "Last logon event: $lastLogon"
}
}
catch {
Write-Log "No logon events found for $Username"
}
# Method 2: Check Recent Items folder
$recentPath = Join-Path -Path $ProfilePath -ChildPath "AppData\Roaming\Microsoft\Windows\Recent"
if (Test-Path $recentPath) {
$recentFiles = Get-ChildItem -Path $recentPath -File -Recurse -ErrorAction SilentlyContinue
if ($recentFiles) {
$lastRecent = ($recentFiles | Measure-Object LastWriteTime -Maximum).Maximum
$activityDates += $lastRecent
Write-Log "Recent items last activity: $lastRecent"
}
}
# Method 3: Check high-activity folders
$activityPaths = @(
"AppData\Local\Microsoft\Windows\Explorer",
"AppData\Local\Microsoft\Windows\INetCache",
"Downloads",
"Desktop"
)
foreach ($path in $activityPaths) {
$fullPath = Join-Path -Path $ProfilePath -ChildPath $path
if (Test-Path $fullPath) {
$items = Get-ChildItem -Path $fullPath -Recurse -File -ErrorAction SilentlyContinue
if ($items) {
$lastActivity = ($items | Measure-Object LastWriteTime -Maximum).Maximum
$activityDates += $lastActivity
Write-Log "$path last activity: $lastActivity"
}
}
}
if ($activityDates.Count -gt 0) {
$mostRecent = ($activityDates | Measure-Object -Maximum).Maximum
Write-Log "Most recent activity for $Username : $mostRecent"
return $mostRecent
}
}
catch {
Write-Log "Error checking profile activity for $Username : $_"
}
Write-Log "No activity dates found for $Username"
return $null
}
function Remove-ProfileRegistry {
param (
[string]$Username,
[string]$SID
)
Write-Log "Starting registry cleanup for user: $Username (SID: $SID)"
$removedKeys = @()
try {
# Registry paths to check
$registryPaths = @(
"HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$SID",
"HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileGuid\$SID",
"HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles\$SID",
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\UserAssist\$SID",
"HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp\UserProfiles\$SID",
"HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers\$SID",
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders\$SID",
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\KnownFolders\$SID"
)
foreach ($path in $registryPaths) {
if (Test-Path $path) {
Write-Log "Removing registry key: $path"
Remove-Item -Path $path -Force -Recurse
$removedKeys += $path
}
}
# Additional parent paths to check
$parentPaths = @(
"HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList",
"HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileGuid"
)
foreach ($parentPath in $parentPaths) {
if (Test-Path $parentPath) {
Get-ChildItem -Path $parentPath -ErrorAction SilentlyContinue | ForEach-Object {
if ($_.PSChildName -eq $SID) {
Write-Log "Removing registry key: $($_.PSPath)"
Remove-Item -Path $_.PSPath -Force -Recurse
$removedKeys += $_.PSPath
}
}
}
}
Write-Log "Registry cleanup completed for user: $Username"
Write-Log "Removed keys: $($removedKeys -join ', ')"
return $true
}
catch {
Write-Log "Error during registry cleanup for $Username : $_"
return $false
}
}
function Send-ProfileReport {
param (
[array]$DeletedProfiles
)
if ($DeletedProfiles.Count -eq 0) {
Write-Log "No profiles were deleted - skipping email report"
return
}
$EmailBody = @"
<html>
<body style="font-family: Arial, sans-serif;">
<h2>Profile Cleanup Report - $ComputerName</h2>
<p>Date: $Date</p>
<table style="border-collapse: collapse; width: 100%;">
<tr style="background-color: #f2f2f2;">
<th style="border: 1px solid #ddd; padding: 8px; text-align: left;">Username</th>
<th style="border: 1px solid #ddd; padding: 8px; text-align: left;">Last Activity</th>
<th style="border: 1px solid #ddd; padding: 8px; text-align: left;">Profile Path</th>
<th style="border: 1px solid #ddd; padding: 8px; text-align: left;">Action</th>
</tr>
"@
foreach ($profile in $DeletedProfiles) {
$EmailBody += @"
<tr>
<td style="border: 1px solid #ddd; padding: 8px;">{0}</td>
<td style="border: 1px solid #ddd; padding: 8px;">{1}</td>
<td style="border: 1px solid #ddd; padding: 8px;">{2}</td>
<td style="border: 1px solid #ddd; padding: 8px;">{3}</td>
</tr>
"@ -f $profile.Username, $profile.LastActivity, $profile.ProfilePath, $profile.Action
}
$EmailBody += @"
</table>
<p style="color: #666;">Generated by Profile Cleanup Script on $ComputerName</p>
<p style="color: #666;">Complete report saved to: $ReportPath</p>
<p style="color: #666;">Detailed log saved to: $DetailedReportPath</p>
</body>
</html>
"@
$EmailParams = @{
From = $EmailFrom
To = $EmailTo
Subject = "Profile Cleanup Report - $ComputerName - $Date"
Body = $EmailBody
SmtpServer = $SmtpServer
BodyAsHtml = $true
}
try {
Send-MailMessage @EmailParams
Write-Log "Email report sent successfully"
}
catch {
Write-Log "Failed to send email report: $_"
}
}
# Main script execution
Write-Log "Starting profile cleanup on $ComputerName"
Write-Log "Excluded users: $($ExcludedUsers -join ', ')"
# Get all user profiles
$userProfiles = Get-WmiObject -Class Win32_UserProfile | Where-Object {
-not $_.Special -and $_.LocalPath -match 'C:\\Users\\'
}
# Initialize report arrays
$Report = @()
$deletedProfiles = @()
# Process each profile
foreach ($profile in $userProfiles) {
try {
$username = Split-Path $profile.LocalPath -Leaf
# Skip excluded users
if ($ExcludedUsers -contains $username) {
Write-Log "Skipping excluded user: $username"
continue
}
Write-Log "Processing profile: $username"
# Get last activity
$lastActivity = Get-ProfileLastActivity -ProfilePath $profile.LocalPath -Username $username
Write-Log "Last detected activity: $lastActivity"
# Check if profile should be kept
if ($lastActivity -and $lastActivity -gt $CutoffDate) {
Write-Log "Profile is active - keeping: $username"
continue
}
# Create report entry
$reportEntry = [PSCustomObject]@{
ComputerName = $ComputerName
Username = $username
LastActivity = $lastActivity
ProfilePath = $profile.LocalPath
DateProcessed = $Date
Action = if ($WhatIf) { "Would be deleted (WhatIf)" } else { "Deleted" }
}
# Process deletion
if (-not $WhatIf) {
Write-Log "Deleting profile: $username"
# Get user SID before deletion
$userSID = Get-UserSID -Username $username -ProfilePath $profile.LocalPath
if ($userSID) {
Write-Log "Found SID for $username : $userSID"
# Delete profile
$profile.Delete()
Write-Log "Profile deleted successfully"
# Clean up registry
$registryCleanup = Remove-ProfileRegistry -Username $username -SID $userSID
if ($registryCleanup) {
Write-Log "Registry cleanup completed successfully for $username"
$reportEntry.Action = "Deleted (with registry cleanup)"
}
else {
Write-Log "Registry cleanup failed for $username"
$reportEntry.Action = "Deleted (registry cleanup failed)"
}
}
else {
Write-Log "Could not find SID for $username - proceeding with profile deletion only"
$profile.Delete()
$reportEntry.Action = "Deleted (no registry cleanup)"
}
}
else {
Write-Log "WhatIf: Would delete profile and registry entries for: $username"
$reportEntry.Action = "Would be deleted with registry cleanup (WhatIf)"
}
$Report += $reportEntry
$deletedProfiles += $reportEntry
}
catch {
Write-Log "Error processing profile $username : $_"
}
Write-Host ("-" * 50)
}
# Export full report to CSV
$Report | Export-Csv -Path $ReportPath -NoTypeInformation
Write-Log "Full report exported to: $ReportPath"
# Send email report if requested
if ($SendEmail) {
Send-ProfileReport -DeletedProfiles $deletedProfiles
}
Write-Log "Profile cleanup completed on $ComputerName"