Powershell : Cleaning up old profiles


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

  1. NTUSER.DAT last write time in C:\Users[Username]\NTUSER.DAT
  2. Recent Items folder activity in C:\Users[Username]\AppData\Roaming\Microsoft\Windows\Recent
  3. Event Log entries for logons (Security Event ID 4624)

Activity in these profile folders:

  1. AppData\Local\Microsoft\Windows\UsrClass.dat
  2. AppData\Local\Microsoft\Windows\Explorer
  3. AppData\Local\Microsoft\Windows\FileHistory\Data
  4. AppData\Local\Temp
  5. AppData\Local\Microsoft\Windows\History
  6. AppData\Local\Microsoft\Windows\INetCache

Profile Validation:

  1. Confirms profile is not in excluded users list
  2. Verifies it's not a special system profile
  3. Checks if profile path exists in C:\Users
  4. Validates SID exists for the profile
What is removed?

Profile Directory:

  1. Complete user profile folder at C:\Users[Username]
  2. All contents and subdirectories
Registry Locations (using user's specific SID):
  1. HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\[SID]
  2. HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileGuid\[SID]
  3. HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles\[SID]
  4. HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\UserAssist\[SID]
  5. HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp\UserProfiles\[SID]
  6. HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers\[SID]
  7. HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders\[SID]
  8. HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\KnownFolders\[SID]
GUID registry Searches:
  1. Scans these parent paths for the user's SID:
  2. HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList
  3. 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"

The email option will look like this when you get the email in your inbox:


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"
Previous Post Next Post

نموذج الاتصال