From 43a8c3cb37b3e5ee83f5e9472897831d653f0ae0 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Thu, 9 Apr 2026 11:35:54 -0700 Subject: [PATCH 1/2] Add stale Entra profile cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shared Windows devices accumulate C:\Users\ entries for Entra ID sign-ins whose local cached account gets removed (via password rotation, OS reset, policy change, etc.) but whose profile folder and ProfileList registry entry survive. Over time these "orphan" profiles consume disk space and clutter the user list. This patch extends ManageUsersEngine to sweep them the same way we sweep regular inactive accounts, reusing the existing per-area/location deletion policy. - New StaleProfileInfo model (folder name, path, SID, creation, last use, whether a ProfileList registry entry exists). - UserEnumerationService.GetStaleProfiles() enumerates C:\Users and returns entries that: * aren't system folders (Public, Default, Default User, All Users), * aren't in the exclusions list, * have no matching local account, joining each with its ProfileList entry (by LocalPath) when available, and falling back to NTUSER.DAT's last write time when ProfileList doesn't record a last-use timestamp. - UserDeletionService.RemoveStaleProfile() kills any lingering procs owned by the user, removes the ProfileList registry subtree (if a SID is known), then deletes the profile folder. Honours the -simulate flag. - ManageUsersEngine wires it into the run loop alongside the orphaned- user and hidden-users passes, and applies the same DeletionPolicy (CreationOnly / LoginAndCreation / never-delete / force-term). Also drops the "no users to process — exit" early return so stale profiles still get swept on devices where nobody has logged in recently. --- src/ManageUsers/Models/StaleProfileInfo.cs | 15 ++++ src/ManageUsers/Services/ManageUsersEngine.cs | 70 +++++++++++++++-- .../Services/UserDeletionService.cs | 51 ++++++++++++ .../Services/UserEnumerationService.cs | 78 +++++++++++++++++++ 4 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 src/ManageUsers/Models/StaleProfileInfo.cs diff --git a/src/ManageUsers/Models/StaleProfileInfo.cs b/src/ManageUsers/Models/StaleProfileInfo.cs new file mode 100644 index 0000000..8768b34 --- /dev/null +++ b/src/ManageUsers/Models/StaleProfileInfo.cs @@ -0,0 +1,15 @@ +namespace ManageUsers.Models; + +/// +/// A profile folder in C:\Users with no corresponding local user account. +/// Typically an Entra ID cached profile left behind after the user stopped logging in. +/// +public sealed class StaleProfileInfo +{ + public required string FolderName { get; init; } + public required string ProfilePath { get; init; } + public string? Sid { get; init; } + public DateTime CreationDate { get; init; } + public DateTime? LastUseTime { get; init; } + public bool HasRegistryEntry { get; init; } +} diff --git a/src/ManageUsers/Services/ManageUsersEngine.cs b/src/ManageUsers/Services/ManageUsersEngine.cs index 445ea09..e884d09 100644 --- a/src/ManageUsers/Services/ManageUsersEngine.cs +++ b/src/ManageUsers/Services/ManageUsersEngine.cs @@ -55,12 +55,6 @@ public int Run() var users = _enum.GetUserSessions(exclusions); _log.Info($"Found {users.Count} non-excluded user(s) to evaluate"); - if (users.Count == 0) - { - _log.Info("No users to process — exiting"); - return 0; - } - // Repair user states _repair.RepairUserStates(users); @@ -94,6 +88,21 @@ public int Run() deletedCount += orphans.Count; } + // Clean up stale Entra/cached profiles (no local account) + var staleProfiles = _enum.GetStaleProfiles(exclusions); + if (staleProfiles.Count > 0) + { + _log.Info($"Found {staleProfiles.Count} stale profile(s) with no local account"); + foreach (var profile in staleProfiles) + { + if (EvaluateStaleProfile(profile, policy, now)) + { + if (_delete.RemoveStaleProfile(profile)) + deletedCount++; + } + } + } + // Update hidden users on login screen _repair.UpdateHiddenUsers(exclusions); @@ -164,4 +173,53 @@ private bool EvaluateUser(UserSessionInfo user, DeletionPolicy policy, DateTime return false; } } + + private bool EvaluateStaleProfile(StaleProfileInfo profile, DeletionPolicy policy, DateTime now) + { + if (policy.ForceTermDeletion) + { + _log.Info($"End-of-term force deletion: stale profile {profile.FolderName}"); + return true; + } + + if (policy.DurationDays < 0) + { + _log.Info($"Never-delete policy: stale profile {profile.FolderName} — keep"); + return false; + } + + var threshold = TimeSpan.FromDays(policy.DurationDays); + + switch (policy.Strategy) + { + case DeletionStrategy.CreationOnly: + { + var age = now - profile.CreationDate; + if (age >= threshold) + { + _log.Info($"CreationOnly: stale profile {profile.FolderName} created {age.Days}d ago (threshold {policy.DurationDays}d) — DELETE"); + return true; + } + _log.Info($"CreationOnly: stale profile {profile.FolderName} created {age.Days}d ago (threshold {policy.DurationDays}d) — keep"); + return false; + } + + case DeletionStrategy.LoginAndCreation: + { + var creationAge = now - profile.CreationDate; + var lastUseAge = profile.LastUseTime.HasValue ? now - profile.LastUseTime.Value : TimeSpan.MaxValue; + + if (creationAge >= threshold && lastUseAge >= threshold) + { + _log.Info($"LoginAndCreation: stale profile {profile.FolderName} created {creationAge.Days}d ago, last use {(profile.LastUseTime.HasValue ? $"{lastUseAge.Days}d ago" : "never")} (threshold {policy.DurationDays}d) — DELETE"); + return true; + } + _log.Info($"LoginAndCreation: stale profile {profile.FolderName} created {creationAge.Days}d ago, last use {(profile.LastUseTime.HasValue ? $"{lastUseAge.Days}d ago" : "never")} (threshold {policy.DurationDays}d) — keep"); + return false; + } + + default: + return false; + } + } } diff --git a/src/ManageUsers/Services/UserDeletionService.cs b/src/ManageUsers/Services/UserDeletionService.cs index ed2a86e..364633b 100644 --- a/src/ManageUsers/Services/UserDeletionService.cs +++ b/src/ManageUsers/Services/UserDeletionService.cs @@ -106,6 +106,57 @@ public void RemoveOrphanedUsers(List orphans, SessionsData sessions) } } + /// + /// Remove a stale Entra/cached profile — no local account exists, just registry + folder. + /// + public bool RemoveStaleProfile(StaleProfileInfo profile) + { + if (_simulate) + { + _log.Info($"[SIMULATE] Would remove stale profile: {profile.FolderName}"); + return true; + } + + _log.Info($"Removing stale profile: {profile.FolderName}"); + + // Kill any lingering processes owned by this user + KillUserProcesses(profile.FolderName); + + // Remove ProfileList registry entry if present + if (profile.HasRegistryEntry && profile.Sid != null) + { + try + { + using var profileList = Registry.LocalMachine.OpenSubKey( + @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList", writable: true); + profileList?.DeleteSubKeyTree(profile.Sid, throwOnMissingSubKey: false); + _log.Info($"Registry entry removed for {profile.FolderName} (SID: {profile.Sid})"); + } + catch (Exception ex) + { + _log.Warning($"Failed to remove registry entry for {profile.FolderName}: {ex.Message}"); + } + } + + // Delete profile folder + if (Directory.Exists(profile.ProfilePath)) + { + try + { + Directory.Delete(profile.ProfilePath, recursive: true); + _log.Info($"Profile directory removed: {profile.ProfilePath}"); + } + catch (Exception ex) + { + _log.Warning($"Failed to remove profile directory {profile.ProfilePath}: {ex.Message}"); + return false; + } + } + + _log.Info($"Successfully removed stale profile: {profile.FolderName}"); + return true; + } + private void DeferDelete(string username, SessionsData sessions) { if (!sessions.DeferredDeletes.Contains(username, StringComparer.OrdinalIgnoreCase)) diff --git a/src/ManageUsers/Services/UserEnumerationService.cs b/src/ManageUsers/Services/UserEnumerationService.cs index 3834fe6..ba8942d 100644 --- a/src/ManageUsers/Services/UserEnumerationService.cs +++ b/src/ManageUsers/Services/UserEnumerationService.cs @@ -64,6 +64,61 @@ public List GetUserSessions(HashSet exclusions) return results; } + /// + /// Finds profile folders in C:\Users that have no corresponding local user account. + /// These are typically Entra ID cached profiles that accumulate on shared devices. + /// + public List GetStaleProfiles(HashSet exclusions) + { + var results = new List(); + var localUsers = EnumerateLocalUsers(); + var localUserNames = new HashSet( + localUsers.Select(u => u.Name), StringComparer.OrdinalIgnoreCase); + var profiles = LoadProfiles(); + + var usersDir = @"C:\Users"; + if (!Directory.Exists(usersDir)) return results; + + foreach (var dir in Directory.GetDirectories(usersDir)) + { + var folderName = Path.GetFileName(dir); + if (folderName == null) continue; + + // Skip system folders + if (SystemProfileFolders.Contains(folderName)) continue; + + // Skip excluded users + if (exclusions.Contains(folderName)) continue; + + // Skip if there's a matching local account + if (localUserNames.Contains(folderName)) continue; + + // Find matching registry entry by profile path + var profileEntry = profiles.Values.FirstOrDefault(p => + p.LocalPath.Equals(dir, StringComparison.OrdinalIgnoreCase)); + + DateTime creationDate; + try { creationDate = Directory.GetCreationTime(dir); } + catch { creationDate = DateTime.Now; } + + var lastUseTime = profileEntry?.LastUseTime ?? GetFolderLastActivity(dir); + + results.Add(new StaleProfileInfo + { + FolderName = folderName, + ProfilePath = dir, + Sid = profileEntry?.Sid, + CreationDate = creationDate, + LastUseTime = lastUseTime, + HasRegistryEntry = profileEntry != null + }); + + _log.Info($"Stale profile: {folderName} | Created: {creationDate:yyyy-MM-dd} | LastUse: {lastUseTime?.ToString("yyyy-MM-dd") ?? "unknown"} | Registry: {(profileEntry != null ? "yes" : "no")}"); + } + + return results; + } + public List FindOrphanedUsers(HashSet exclusions) { var orphans = new List(); @@ -270,4 +325,27 @@ private sealed class ProfileInfo public required string LocalPath { get; init; } public DateTime? LastUseTime { get; init; } } + + #region Stale Profile Helpers + + private static readonly HashSet SystemProfileFolders = new(StringComparer.OrdinalIgnoreCase) + { + "Public", "Default", "Default User", "All Users" + }; + + private static DateTime? GetFolderLastActivity(string path) + { + try + { + // NTUSER.DAT last write time is the best proxy for last interactive use + var ntuserDat = Path.Combine(path, "NTUSER.DAT"); + if (File.Exists(ntuserDat)) + return File.GetLastWriteTime(ntuserDat); + + return Directory.GetLastWriteTime(path); + } + catch { return null; } + } + + #endregion } From 97a6789027f37931ddcdc0da2c54530a13a076d3 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Thu, 9 Apr 2026 11:53:06 -0700 Subject: [PATCH 2/2] Address Copilot review feedback on stale profile cleanup Five fixes from Copilot review of the stale profile sweep: 1. StaleProfileInfo: sealed class -> sealed record. Caller treats instances as value-ish (no mutation, no reference identity) and PR description called it a "record", so make it one. 2. GetStaleProfiles: switch from folder-name matching to SID-based local-user matching. Previously, a profile folder whose name didn't exactly match a local user name (e.g. collision suffix "jsmith.ECU" for account "jsmith") could be misclassified as stale and eligible for deletion. Now we resolve every local account to a SID, walk ProfileList, and collect the LocalPath of any entry whose SID is a real local account. Paths in that set are skipped regardless of folder-name shape. The old folder-name check is preserved as a fallback for brand-new accounts that don't have a ProfileList entry yet. 3. ProfileList join: normalize both sides before comparing. ProfileImagePath values can contain unexpanded env vars, trailing separators, or non-canonical forms, which made p.LocalPath.Equals(dir) miss legitimate matches. Added a NormalizeProfilePath helper (ExpandEnvironmentVariables + GetFullPath + trim trailing separators) and pre-index ProfileList entries by the normalized path for O(1) lookup during the scan. 4. GetCreationTime failure: log + try GetLastWriteTime before DateTime.Now. The previous silent fallback to DateTime.Now could prevent deletion (an infinitely "young" profile never crosses the policy threshold) or hide an underlying FS / ACL problem. Now logs a warning, tries LastWriteTime, and only as a last resort uses DateTime.Now with a second warning. 5. UserDeletionService.RemoveStaleProfile: only log "Registry entry removed" after OpenSubKey actually succeeded. The previous code used profileList?.DeleteSubKeyTree(...) + an unconditional success log, so a null-return from OpenSubKey would silently skip deletion while still logging success. Split into an explicit if/else with a warning branch so operators see when the key couldn't be opened at all. --- src/ManageUsers/Models/StaleProfileInfo.cs | 2 +- .../Services/UserDeletionService.cs | 11 ++- .../Services/UserEnumerationService.cs | 96 +++++++++++++++++-- 3 files changed, 98 insertions(+), 11 deletions(-) diff --git a/src/ManageUsers/Models/StaleProfileInfo.cs b/src/ManageUsers/Models/StaleProfileInfo.cs index 8768b34..fb8ed9c 100644 --- a/src/ManageUsers/Models/StaleProfileInfo.cs +++ b/src/ManageUsers/Models/StaleProfileInfo.cs @@ -4,7 +4,7 @@ namespace ManageUsers.Models; /// A profile folder in C:\Users with no corresponding local user account. /// Typically an Entra ID cached profile left behind after the user stopped logging in. /// -public sealed class StaleProfileInfo +public sealed record StaleProfileInfo { public required string FolderName { get; init; } public required string ProfilePath { get; init; } diff --git a/src/ManageUsers/Services/UserDeletionService.cs b/src/ManageUsers/Services/UserDeletionService.cs index 364633b..5f8ff26 100644 --- a/src/ManageUsers/Services/UserDeletionService.cs +++ b/src/ManageUsers/Services/UserDeletionService.cs @@ -129,8 +129,15 @@ public bool RemoveStaleProfile(StaleProfileInfo profile) { using var profileList = Registry.LocalMachine.OpenSubKey( @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList", writable: true); - profileList?.DeleteSubKeyTree(profile.Sid, throwOnMissingSubKey: false); - _log.Info($"Registry entry removed for {profile.FolderName} (SID: {profile.Sid})"); + if (profileList != null) + { + profileList.DeleteSubKeyTree(profile.Sid, throwOnMissingSubKey: false); + _log.Info($"Registry entry removed for {profile.FolderName} (SID: {profile.Sid})"); + } + else + { + _log.Warning($"Could not open ProfileList registry key; registry entry was not removed for {profile.FolderName} (SID: {profile.Sid})"); + } } catch (Exception ex) { diff --git a/src/ManageUsers/Services/UserEnumerationService.cs b/src/ManageUsers/Services/UserEnumerationService.cs index ba8942d..5f27c87 100644 --- a/src/ManageUsers/Services/UserEnumerationService.cs +++ b/src/ManageUsers/Services/UserEnumerationService.cs @@ -67,14 +67,54 @@ public List GetUserSessions(HashSet exclusions) /// /// Finds profile folders in C:\Users that have no corresponding local user account. /// These are typically Entra ID cached profiles that accumulate on shared devices. + /// + /// Matching strategy: build the set of SIDs for every local account, then walk + /// ProfileList and collect the LocalPath of any entry whose SID is in that set. + /// Anything in C:\Users not in that path set (and not a system folder / exclusion) + /// is a stale candidate. This avoids the naive foldername-equals-username match + /// which would misclassify collision-suffixed profile directories (e.g. + /// "jsmith.ECU" for local account "jsmith"). /// public List GetStaleProfiles(HashSet exclusions) { var results = new List(); + + // SIDs of every local account (enabled or disabled — we don't want to delete + // a disabled local user's profile by accident). var localUsers = EnumerateLocalUsers(); - var localUserNames = new HashSet( - localUsers.Select(u => u.Name), StringComparer.OrdinalIgnoreCase); + var localUserSids = new HashSet(StringComparer.OrdinalIgnoreCase); + var localUserNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (name, _) in localUsers) + { + localUserNames.Add(name); + var sid = ResolveSid(name); + if (!string.IsNullOrEmpty(sid)) + localUserSids.Add(sid); + } + + // Pre-index ProfileList by normalized LocalPath so the join below is O(1) + // and tolerant of env-var / trailing-separator variation in ProfileImagePath. var profiles = LoadProfiles(); + var profilesByNormalizedPath = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var profile in profiles.Values) + { + var key = NormalizeProfilePath(profile.LocalPath); + if (!string.IsNullOrEmpty(key)) + profilesByNormalizedPath[key] = profile; + } + + // Paths owned by real local accounts — these are NOT stale, even if the + // folder name differs from the account name (collision suffixes, casing, etc). + var localUserProfilePaths = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var profile in profiles.Values) + { + if (localUserSids.Contains(profile.Sid)) + { + var key = NormalizeProfilePath(profile.LocalPath); + if (!string.IsNullOrEmpty(key)) + localUserProfilePaths.Add(key); + } + } var usersDir = @"C:\Users"; if (!Directory.Exists(usersDir)) return results; @@ -90,16 +130,38 @@ public List GetStaleProfiles(HashSet exclusions) // Skip excluded users if (exclusions.Contains(folderName)) continue; - // Skip if there's a matching local account + var normalizedDir = NormalizeProfilePath(dir); + + // Skip if this path is owned by a real local account (via ProfileList). + if (localUserProfilePaths.Contains(normalizedDir)) continue; + + // Fallback: folder-name match for local users that have no ProfileList + // entry yet (brand-new accounts that haven't logged in). Safer than + // deleting someone's home dir due to a missing registry join. if (localUserNames.Contains(folderName)) continue; - // Find matching registry entry by profile path - var profileEntry = profiles.Values.FirstOrDefault(p => - p.LocalPath.Equals(dir, StringComparison.OrdinalIgnoreCase)); + // Look up any ProfileList entry for this directory (helps surface the SID + // for registry cleanup even when no local user owns it). + profilesByNormalizedPath.TryGetValue(normalizedDir, out var profileEntry); DateTime creationDate; - try { creationDate = Directory.GetCreationTime(dir); } - catch { creationDate = DateTime.Now; } + try + { + creationDate = Directory.GetCreationTime(dir); + } + catch (Exception ex) + { + _log.Warning($"Could not get creation time for profile folder '{dir}': {ex.Message}. Falling back to last write time."); + try + { + creationDate = Directory.GetLastWriteTime(dir); + } + catch (Exception fallbackEx) + { + _log.Warning($"Could not get last write time for '{dir}' either: {fallbackEx.Message}. Using DateTime.Now — policy evaluation may be inaccurate."); + creationDate = DateTime.Now; + } + } var lastUseTime = profileEntry?.LastUseTime ?? GetFolderLastActivity(dir); @@ -119,6 +181,24 @@ public List GetStaleProfiles(HashSet exclusions) return results; } + /// + /// Normalize a profile path for comparison: expand env vars, resolve to a + /// canonical full path, and trim trailing separators. Returns empty on failure. + /// + private static string NormalizeProfilePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) return string.Empty; + try + { + var expanded = Environment.ExpandEnvironmentVariables(path); + return Path.GetFullPath(expanded).TrimEnd('\\', '/'); + } + catch + { + return string.Empty; + } + } + public List FindOrphanedUsers(HashSet exclusions) { var orphans = new List();