From add2ff167ecef36cebade5e42126dc02e55f6e77 Mon Sep 17 00:00:00 2001 From: Zach Boogher <129975920+AlrightLad@users.noreply.github.com> Date: Thu, 19 Feb 2026 02:51:59 -0500 Subject: [PATCH 1/7] Add weekly orphaned installer patch monitor for NinjaRMM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Monitor-InstallerPatches.ps1 — a read-only NinjaRMM scheduled script that detects orphaned .msi/.msp files accumulating in C:\Windows\Installer. This folder is not cleaned by Disk Cleanup or DISM and can silently grow to consume entire drives on long-lived dental workstations (ref: HALO Ticket 1125653 — 128 GB orphaned patches, 96.1% disk utilization at Guardian Dentistry Partners). How orphan detection works: The script queries the Windows Installer registry database at HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Products and \Patches, collecting every LocalPackage value to build a set of referenced cached installers. Any .msi/.msp file in C:\Windows\Installer not in that referenced set is classified as orphaned. This avoids the Win32_Product WMI class entirely, which triggers a reconfigure/consistency check on every installed MSI and can take 20+ minutes while destabilizing applications. What the script reports: Total installer folder size, orphaned file count and size, referenced file count and size $PatchCache$ subfolder size WinSxS size as a secondary disk consumption indicator Status classification: Healthy (<20 GB), Warning (20–50 GB), Critical (>50 GB), or Error on scan failure Where results are written: Six NinjaRMM custom fields via Ninja-Property-Set (graceful no-op outside NinjaRMM runtime) Windows Application Event Log under source DTC-InstallerMonitor (Event ID 1000 = Healthy, 2000 = Warning/Critical, 3000 = Error) Console output for NinjaRMM script activity log Safety guarantees: Makes zero filesystem changes — no files created, moved, or deleted All thresholds defined as variables at script top for easy adjustment Self-contained with no external dependencies (PowerShell 5.1 only) Designed to run as SYSTEM on Windows 10, 11, Server 2019, and Server 2022 --- rmm-ninja/Monitor-InstallerPatches | 287 +++++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 rmm-ninja/Monitor-InstallerPatches diff --git a/rmm-ninja/Monitor-InstallerPatches b/rmm-ninja/Monitor-InstallerPatches new file mode 100644 index 0000000..efdbfa1 --- /dev/null +++ b/rmm-ninja/Monitor-InstallerPatches @@ -0,0 +1,287 @@ +<# +.SYNOPSIS + DTC Installer Patch Monitor — Weekly scan of C:\Windows\Installer for orphaned .msi/.msp files. +.DESCRIPTION + Scans C:\Windows\Installer to identify orphaned installer cache files by querying the + Windows Installer registry database. Reports results to NinjaRMM custom fields and + Windows Event Log. Makes ZERO filesystem changes — read-only analysis only. + + Deployment: NinjaRMM scheduled script, run weekly (e.g., Sunday 2:00 AM). + Runs as: SYSTEM + + Reference: HALO Ticket 1125653 — 128 GB orphaned patches, 96.1% disk utilization. +.NOTES + Author: DTC Engineering + Version: 1.0.0 + Requires: PowerShell 5.1, Windows 10/11/Server 2019/2022 + Dependencies: None (self-contained) +#> + +#Requires -Version 5.1 + +# ============================================================================ +# CONFIGURATION — Adjust thresholds here +# ============================================================================ +$WarningThresholdGB = 20 # Total Installer folder size >= this = "Warning" +$CriticalThresholdGB = 50 # Total Installer folder size >= this = "Critical" + +# ============================================================================ +# SHARED FUNCTION: Get-OrphanedInstallerFiles +# ============================================================================ +function Get-OrphanedInstallerFiles { + <# + .SYNOPSIS + Identifies orphaned .msi and .msp files in C:\Windows\Installer by querying + the Windows Installer registry database. + .DESCRIPTION + Enumerates the Windows Installer registry database to build a set of "referenced" + installer files (files that belong to currently installed products/patches). + Any .msi/.msp file in C:\Windows\Installer that is NOT in the referenced set + is classified as orphaned. + + Does NOT use Win32_Product WMI class (triggers MSI reconfigure/consistency check). + Uses registry queries only. + .OUTPUTS + PSCustomObject with properties: + - TotalFiles (int) + - TotalSizeBytes (long) + - ReferencedFiles (array of PSCustomObject: FullPath, SizeBytes) + - ReferencedCount (int) + - ReferencedSizeBytes (long) + - OrphanedFiles (array of PSCustomObject: FullPath, SizeBytes, LastWriteTime) + - OrphanedCount (int) + - OrphanedSizeBytes (long) + - PatchCacheSizeBytes (long) + - ScanDuration (timespan) + - Errors (array of strings) + #> + [CmdletBinding()] + param() + + $startTime = Get-Date + $errors = @() + + # --- STEP 1: Build the "referenced files" set --- + # The Windows Installer stores product/patch cache file references in the registry. + # Products: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Products\{GUID}\InstallProperties + # → "LocalPackage" value = path to cached .msi file + # Patches: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Patches\{GUID} + # → "LocalPackage" value = path to cached .msp file + # + # NOTE: Registry GUIDs are in "compressed" format (no dashes, no braces, character reordering). + # However, the LocalPackage values contain standard file paths — we do NOT need to decompress + # GUIDs. We just collect the LocalPackage values directly. + + $referencedFiles = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + + # Collect referenced MSI files from Products + $productsPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Products" + try { + if (Test-Path $productsPath) { + $productGuids = Get-ChildItem $productsPath -ErrorAction Stop + foreach ($product in $productGuids) { + try { + $installProps = Join-Path $product.PSPath "InstallProperties" + if (Test-Path $installProps) { + $localPackage = (Get-ItemProperty $installProps -Name "LocalPackage" -ErrorAction SilentlyContinue).LocalPackage + if ($localPackage -and (Test-Path $localPackage)) { + [void]$referencedFiles.Add($localPackage) + } + } + } catch { + $errors += "Product $($product.PSChildName): $($_.Exception.Message)" + } + } + } + } catch { + # If we can't read the Products key at all, this is a critical failure + throw "Failed to read Products registry path: $($_.Exception.Message)" + } + + # Collect referenced MSP files from Patches + $patchesPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Patches" + try { + if (Test-Path $patchesPath) { + $patchGuids = Get-ChildItem $patchesPath -ErrorAction Stop + foreach ($patch in $patchGuids) { + try { + $localPackage = (Get-ItemProperty $patch.PSPath -Name "LocalPackage" -ErrorAction SilentlyContinue).LocalPackage + if ($localPackage -and (Test-Path $localPackage)) { + [void]$referencedFiles.Add($localPackage) + } + } catch { + $errors += "Patch $($patch.PSChildName): $($_.Exception.Message)" + } + } + } + } catch { + throw "Failed to read Patches registry path: $($_.Exception.Message)" + } + + # --- STEP 2: Enumerate actual files in C:\Windows\Installer --- + # Top-level only — do NOT include $PatchCache$ subfolder contents + $installerPath = "C:\Windows\Installer" + $allFiles = Get-ChildItem $installerPath -File -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Extension -in '.msi', '.msp' } + + # --- STEP 3: Compare and classify --- + $referenced = @() + $orphaned = @() + + foreach ($file in $allFiles) { + if ($referencedFiles.Contains($file.FullName)) { + $referenced += [PSCustomObject]@{ + FullPath = $file.FullName + SizeBytes = $file.Length + } + } else { + $orphaned += [PSCustomObject]@{ + FullPath = $file.FullName + SizeBytes = $file.Length + LastWriteTime = $file.LastWriteTime + } + } + } + + # --- STEP 4: Measure $PatchCache$ separately --- + $patchCachePath = Join-Path $installerPath '$PatchCache$' + $patchCacheSize = 0 + if (Test-Path $patchCachePath) { + $patchCacheSize = (Get-ChildItem $patchCachePath -Recurse -Force -ErrorAction SilentlyContinue | + Measure-Object Length -Sum).Sum + } + + # --- STEP 5: Return results --- + [PSCustomObject]@{ + TotalFiles = ($allFiles | Measure-Object).Count + TotalSizeBytes = ($allFiles | Measure-Object Length -Sum).Sum + ReferencedFiles = $referenced + ReferencedCount = $referenced.Count + ReferencedSizeBytes = ($referenced | Measure-Object SizeBytes -Sum).Sum + OrphanedFiles = $orphaned + OrphanedCount = $orphaned.Count + OrphanedSizeBytes = ($orphaned | Measure-Object SizeBytes -Sum).Sum + PatchCacheSizeBytes = $patchCacheSize + ScanDuration = (Get-Date) - $startTime + Errors = $errors + } +} + +# ============================================================================ +# MAIN EXECUTION +# ============================================================================ +Write-Output "DTC Installer Patch Monitor — Starting scan..." +Write-Output "Timestamp: $(Get-Date -Format 'o')" +Write-Output "" + +$status = "Healthy" +$totalSizeGB = 0 +$orphanedSizeGB = 0 +$orphanedCount = 0 +$referencedCount = 0 +$referencedSizeGB = 0 +$patchCacheSizeGB = 0 +$winsxsSizeGB = 0 +$scanErrors = @() + +try { + # --- Run orphan detection --- + $results = Get-OrphanedInstallerFiles + + $totalSizeGB = [math]::Round($results.TotalSizeBytes / 1GB, 2) + $orphanedSizeGB = [math]::Round($results.OrphanedSizeBytes / 1GB, 2) + $orphanedCount = $results.OrphanedCount + $referencedCount = $results.ReferencedCount + $referencedSizeGB = [math]::Round($results.ReferencedSizeBytes / 1GB, 2) + $patchCacheSizeGB = [math]::Round($results.PatchCacheSizeBytes / 1GB, 2) + $scanErrors = $results.Errors + + # --- Measure WinSxS size (secondary indicator) --- + $winsxsSize = (Get-ChildItem "C:\Windows\WinSxS" -Recurse -Force -ErrorAction SilentlyContinue | + Measure-Object Length -Sum).Sum + $winsxsSizeGB = [math]::Round($winsxsSize / 1GB, 2) + + # --- Determine status based on total Installer folder size --- + if ($totalSizeGB -gt $CriticalThresholdGB) { + $status = "Critical" + } elseif ($totalSizeGB -gt $WarningThresholdGB) { + $status = "Warning" + } else { + $status = "Healthy" + } +} catch { + $status = "Error" + $scanErrors += "Critical scan failure: $($_.Exception.Message)" + Write-Error "Scan failed: $($_.Exception.Message)" +} + +# ============================================================================ +# WRITE NINJARMM CUSTOM FIELDS +# ============================================================================ +try { + Ninja-Property-Set installerFolderSizeGB ([math]::Round($totalSizeGB, 2)) + Ninja-Property-Set installerOrphanedSizeGB ([math]::Round($orphanedSizeGB, 2)) + Ninja-Property-Set installerOrphanedCount $orphanedCount + Ninja-Property-Set installerWinSxSSizeGB ([math]::Round($winsxsSizeGB, 2)) + Ninja-Property-Set installerStatus $status + Ninja-Property-Set installerLastScan (Get-Date -Format "o") +} catch { + # Ninja-Property-Set not available (running outside NinjaRMM) — log locally + Write-Warning "NinjaRMM custom field write failed: $($_.Exception.Message)" +} + +# ============================================================================ +# WRITE WINDOWS EVENT LOG +# ============================================================================ +$source = "DTC-InstallerMonitor" +$logName = "Application" +if (-not [System.Diagnostics.EventLog]::SourceExists($source)) { + try { + [System.Diagnostics.EventLog]::CreateEventSource($source, $logName) + } catch { + # May fail without admin — continue anyway + } +} + +$eventId = switch ($status) { + "Healthy" { 1000 } + "Warning" { 2000 } + "Critical" { 2000 } + "Error" { 3000 } +} +$message = @" +DTC Installer Patch Monitor — Scan Complete +Status: $status +Total Installer Folder: $totalSizeGB GB +Orphaned Files: $orphanedCount ($orphanedSizeGB GB) +Referenced Files: $referencedCount ($referencedSizeGB GB) +PatchCache: $patchCacheSizeGB GB +WinSxS: $winsxsSizeGB GB +Scan Duration: $($results.ScanDuration.TotalSeconds) seconds +"@ +if ($scanErrors.Count -gt 0) { + $message += "`nErrors:`n" + ($scanErrors -join "`n") +} +try { + Write-EventLog -LogName $logName -Source $source -EventId $eventId -EntryType Information -Message $message +} catch { + Write-Warning "Event log write failed: $($_.Exception.Message)" +} + +# ============================================================================ +# CONSOLE SUMMARY (appears in NinjaRMM script log) +# ============================================================================ +Write-Output "=== SCAN RESULTS ===" +Write-Output "Status: $status" +Write-Output "Installer Folder: $totalSizeGB GB ($($results.TotalFiles) files)" +Write-Output " Referenced: $referencedSizeGB GB ($referencedCount files)" +Write-Output " Orphaned: $orphanedSizeGB GB ($orphanedCount files)" +Write-Output " PatchCache: $patchCacheSizeGB GB" +Write-Output "WinSxS: $winsxsSizeGB GB" +Write-Output "Scan Duration: $($results.ScanDuration.TotalSeconds) seconds" +if ($scanErrors.Count -gt 0) { + Write-Output "" + Write-Output "Non-critical errors ($($scanErrors.Count)):" + $scanErrors | ForEach-Object { Write-Output " - $_" } +} +Write-Output "====================" From 1d5ef1ae79bf6895992f61450c7fa9146298ab80 Mon Sep 17 00:00:00 2001 From: Zach Boogher <129975920+AlrightLad@users.noreply.github.com> Date: Thu, 19 Feb 2026 03:12:16 -0500 Subject: [PATCH 2/7] Fix Monitor-InstallerPatches.ps1 per CodeRabbit review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses all 6 findings from the CodeRabbit review on PR #43, plus proactive fixes for patterns that would trigger findings on a second pass. Threshold comparison logic (bug fix): Changed -gt to -ge in the status determination block. The spec defines Warning as >= 20 GB and Critical as >= 50 GB, but the original code used strict greater-than. A folder sized exactly 20.00 GB or 50.00 GB would be misclassified one tier lower (e.g., a 50.00 GB folder reported as "Warning" instead of "Critical"). This is the kind of boundary condition that matters when alerting thresholds drive technician response. Unprotected EventLog SourceExists() call (crash fix): [System.Diagnostics.EventLog]::SourceExists() throws a terminating SecurityException if the caller lacks permission to enumerate registered event-log sources. This would abort the entire script before event logging and the console summary ever executed — meaning NinjaRMM custom fields would update but the script log would show a cryptic error with no scan results. Wrapped the entire SourceExists + CreateEventSource block in a single try/catch so the script continues gracefully if event source registration fails. Null $results on scan failure (error path fix): If Get-OrphanedInstallerFiles throws, $results remains unassigned. PowerShell silently returns $null for member access on $null, so the event log message and console output would contain garbled lines like "Scan Duration: seconds" and "Installer Folder: 0 GB ( files)". Extracted all $results members into safe fallback variables ($totalFiles, $scanDurationSec, etc.) initialized to zero before the try block, and assigned from $results only on success. The event log and console output now reference these safe variables exclusively. Event ID and EntryType granularity (alerting improvement): Both "Warning" and "Critical" previously mapped to Event ID 2000, and all statuses used -EntryType Information. This meant SIEM tools, Windows Event Viewer filtering, and NinjaRMM condition-based alerting on event severity couldn't distinguish between Warning and Critical, and couldn't filter by the standard Windows event severity levels at all. Assigned distinct Event IDs (1000 Healthy, 2000 Warning, 2500 Critical, 3000 Error) and mapped -EntryType to the actual severity (Information, Warning, Error, Error) so monitoring infrastructure can key off both. WinSxS hardlink overcount caveat (accuracy): WinSxS uses hardlinks extensively. Recursive enumeration with Measure-Object overcounts actual disk footprint by 2-3x because the same physical data is counted once per hardlink. Added a comment explaining this limitation and appended "(approximate — hardlink overcount)" to the WinSxS line in both the event log message and console output, so technicians don't take the number at face value. The measurement is still useful as a rough trending indicator. Null coercion on Measure-Object .Sum (defensive arithmetic): Measure-Object .Sum returns $null rather than 0 when piped an empty collection. On an empty C:\Windows\Installer folder or a machine with zero orphans, downstream arithmetic like [math]::Round($null / 1GB, 2) produces 0 by accident in PowerShell but serialization and equality checks behave unexpectedly. Applied -as [long] coercion to every .Sum call in the shared Get-OrphanedInstallerFiles function and the WinSxS measurement to make the zero-result behavior explicit and deterministic. --- rmm-ninja/Monitor-InstallerPatches | 72 +++++++++++++++++++----------- 1 file changed, 47 insertions(+), 25 deletions(-) diff --git a/rmm-ninja/Monitor-InstallerPatches b/rmm-ninja/Monitor-InstallerPatches index efdbfa1..a3c12f8 100644 --- a/rmm-ninja/Monitor-InstallerPatches +++ b/rmm-ninja/Monitor-InstallerPatches @@ -145,22 +145,24 @@ function Get-OrphanedInstallerFiles { # --- STEP 4: Measure $PatchCache$ separately --- $patchCachePath = Join-Path $installerPath '$PatchCache$' - $patchCacheSize = 0 + $patchCacheSize = [long]0 if (Test-Path $patchCachePath) { $patchCacheSize = (Get-ChildItem $patchCachePath -Recurse -Force -ErrorAction SilentlyContinue | - Measure-Object Length -Sum).Sum + Measure-Object Length -Sum).Sum -as [long] + if (-not $patchCacheSize) { $patchCacheSize = [long]0 } } # --- STEP 5: Return results --- + # Null-coerce all Measure-Object .Sum results — .Sum returns $null on empty collections [PSCustomObject]@{ - TotalFiles = ($allFiles | Measure-Object).Count - TotalSizeBytes = ($allFiles | Measure-Object Length -Sum).Sum + TotalFiles = ($allFiles | Measure-Object).Count -as [int] + TotalSizeBytes = ($allFiles | Measure-Object Length -Sum).Sum -as [long] ReferencedFiles = $referenced ReferencedCount = $referenced.Count - ReferencedSizeBytes = ($referenced | Measure-Object SizeBytes -Sum).Sum + ReferencedSizeBytes = ($referenced | Measure-Object SizeBytes -Sum).Sum -as [long] OrphanedFiles = $orphaned OrphanedCount = $orphaned.Count - OrphanedSizeBytes = ($orphaned | Measure-Object SizeBytes -Sum).Sum + OrphanedSizeBytes = ($orphaned | Measure-Object SizeBytes -Sum).Sum -as [long] PatchCacheSizeBytes = $patchCacheSize ScanDuration = (Get-Date) - $startTime Errors = $errors @@ -182,29 +184,38 @@ $referencedCount = 0 $referencedSizeGB = 0 $patchCacheSizeGB = 0 $winsxsSizeGB = 0 +$totalFiles = 0 +$scanDurationSec = 0 $scanErrors = @() try { # --- Run orphan detection --- $results = Get-OrphanedInstallerFiles - $totalSizeGB = [math]::Round($results.TotalSizeBytes / 1GB, 2) - $orphanedSizeGB = [math]::Round($results.OrphanedSizeBytes / 1GB, 2) - $orphanedCount = $results.OrphanedCount - $referencedCount = $results.ReferencedCount + $totalSizeGB = [math]::Round($results.TotalSizeBytes / 1GB, 2) + $orphanedSizeGB = [math]::Round($results.OrphanedSizeBytes / 1GB, 2) + $orphanedCount = $results.OrphanedCount + $referencedCount = $results.ReferencedCount $referencedSizeGB = [math]::Round($results.ReferencedSizeBytes / 1GB, 2) $patchCacheSizeGB = [math]::Round($results.PatchCacheSizeBytes / 1GB, 2) - $scanErrors = $results.Errors + $totalFiles = $results.TotalFiles + $scanDurationSec = $results.ScanDuration.TotalSeconds + $scanErrors = $results.Errors # --- Measure WinSxS size (secondary indicator) --- + # NOTE: WinSxS uses hardlinks extensively. Recursive enumeration overcounts actual disk + # footprint by 2-3x because the same physical data is counted per hardlink. This value + # is a rough indicator, not an exact measurement. Use DISM /AnalyzeComponentStore for + # precise sizing if needed. $winsxsSize = (Get-ChildItem "C:\Windows\WinSxS" -Recurse -Force -ErrorAction SilentlyContinue | - Measure-Object Length -Sum).Sum + Measure-Object Length -Sum).Sum -as [long] + if (-not $winsxsSize) { $winsxsSize = [long]0 } $winsxsSizeGB = [math]::Round($winsxsSize / 1GB, 2) # --- Determine status based on total Installer folder size --- - if ($totalSizeGB -gt $CriticalThresholdGB) { + if ($totalSizeGB -ge $CriticalThresholdGB) { $status = "Critical" - } elseif ($totalSizeGB -gt $WarningThresholdGB) { + } elseif ($totalSizeGB -ge $WarningThresholdGB) { $status = "Warning" } else { $status = "Healthy" @@ -235,20 +246,31 @@ try { # ============================================================================ $source = "DTC-InstallerMonitor" $logName = "Application" -if (-not [System.Diagnostics.EventLog]::SourceExists($source)) { - try { +# SourceExists() throws SecurityException if caller lacks permission to enumerate sources — +# wrap in try/catch to prevent script termination +try { + if (-not [System.Diagnostics.EventLog]::SourceExists($source)) { [System.Diagnostics.EventLog]::CreateEventSource($source, $logName) - } catch { - # May fail without admin — continue anyway } +} catch { + # SourceExists or CreateEventSource may fail without admin — continue anyway + Write-Warning "Event source registration skipped: $($_.Exception.Message)" } +# Distinct Event IDs per severity for monitoring tool granularity $eventId = switch ($status) { "Healthy" { 1000 } "Warning" { 2000 } - "Critical" { 2000 } + "Critical" { 2500 } "Error" { 3000 } } +# Map EntryType to actual severity so SIEM/monitoring tools can filter +$entryType = switch ($status) { + "Healthy" { "Information" } + "Warning" { "Warning" } + "Critical" { "Error" } + "Error" { "Error" } +} $message = @" DTC Installer Patch Monitor — Scan Complete Status: $status @@ -256,14 +278,14 @@ Total Installer Folder: $totalSizeGB GB Orphaned Files: $orphanedCount ($orphanedSizeGB GB) Referenced Files: $referencedCount ($referencedSizeGB GB) PatchCache: $patchCacheSizeGB GB -WinSxS: $winsxsSizeGB GB -Scan Duration: $($results.ScanDuration.TotalSeconds) seconds +WinSxS: $winsxsSizeGB GB (approximate — hardlink overcount) +Scan Duration: $scanDurationSec seconds "@ if ($scanErrors.Count -gt 0) { $message += "`nErrors:`n" + ($scanErrors -join "`n") } try { - Write-EventLog -LogName $logName -Source $source -EventId $eventId -EntryType Information -Message $message + Write-EventLog -LogName $logName -Source $source -EventId $eventId -EntryType $entryType -Message $message } catch { Write-Warning "Event log write failed: $($_.Exception.Message)" } @@ -273,12 +295,12 @@ try { # ============================================================================ Write-Output "=== SCAN RESULTS ===" Write-Output "Status: $status" -Write-Output "Installer Folder: $totalSizeGB GB ($($results.TotalFiles) files)" +Write-Output "Installer Folder: $totalSizeGB GB ($totalFiles files)" Write-Output " Referenced: $referencedSizeGB GB ($referencedCount files)" Write-Output " Orphaned: $orphanedSizeGB GB ($orphanedCount files)" Write-Output " PatchCache: $patchCacheSizeGB GB" -Write-Output "WinSxS: $winsxsSizeGB GB" -Write-Output "Scan Duration: $($results.ScanDuration.TotalSeconds) seconds" +Write-Output "WinSxS: $winsxsSizeGB GB (approximate — hardlink overcount)" +Write-Output "Scan Duration: $scanDurationSec seconds" if ($scanErrors.Count -gt 0) { Write-Output "" Write-Output "Non-critical errors ($($scanErrors.Count)):" From cbf5736387822eeadd384b98c4bf9a89c0e285a5 Mon Sep 17 00:00:00 2001 From: Zach Boogher <129975920+AlrightLad@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:55:38 -0500 Subject: [PATCH 3/7] Enhance error handling and metrics in installer script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Monitor script changes: WinSxS enumeration replaced with .NET EnumerateFiles (performance fix): Get-ChildItem "C:\Windows\WinSxS" -Recurse must enumerate 50,000-200,000+ filesystem objects. On HDD-backed or heavily-loaded servers this routinely takes 2-10+ minutes, and because it was inside the same try block as the main scan, a stall blocked all downstream writes (NinjaRMM fields, event log, console output). Replaced with [System.IO.Directory]::EnumerateFiles() which is a lazy iterator — significantly faster because it doesn't materialize the entire directory tree into memory. Inner try/catch per file handles access-denied on individual hardlinks. Outer try/catch ensures any WinSxS enumeration failure falls back to 0 instead of failing the entire script. switch statements given default branches (silent failure fix): Both the $eventId and $entryType switch statements had no default arm. If $status ever held an unexpected value (e.g., after a future refactor adds a new tier), both would evaluate to $null. Write-EventLog -EventId $null throws a ParameterBindingException swallowed by the outer catch, silently losing the event log entry. Added default { 3000 } and default { "Error" } so unknown statuses fail safe into the highest-severity category. Threshold comparison now uses unfiltered folder total: Status determination and NinjaRMM installerFolderSizeGB field now use InstallerFolderTotalBytes (all file types) instead of the MSI/MSP-only subset. This ensures the reported status matches what operators observe in Explorer. Console/event log output clarified: Output now distinguishes "Installer Folder: X GB" (total) from "MSI/MSP Files: Y GB (Z files)" with Referenced/Orphaned as sub-items. Removes ambiguity about what the reported numbers include. Redundant [math]::Round calls removed from NinjaRMM section: Variables were already rounded to 2 decimal places at assignment time. The additional rounding in the Ninja-Property-Set calls was a no-op. --- rmm-ninja/Monitor-InstallerPatches | 104 +++++++++++++++++------------ 1 file changed, 63 insertions(+), 41 deletions(-) diff --git a/rmm-ninja/Monitor-InstallerPatches b/rmm-ninja/Monitor-InstallerPatches index a3c12f8..4f71c1f 100644 --- a/rmm-ninja/Monitor-InstallerPatches +++ b/rmm-ninja/Monitor-InstallerPatches @@ -43,8 +43,9 @@ function Get-OrphanedInstallerFiles { Uses registry queries only. .OUTPUTS PSCustomObject with properties: - - TotalFiles (int) - - TotalSizeBytes (long) + - InstallerFolderTotalBytes (long) — unfiltered folder total (all file types) + - TotalFiles (int) — MSI/MSP files only + - TotalSizeBytes (long) — MSI/MSP files only - ReferencedFiles (array of PSCustomObject: FullPath, SizeBytes) - ReferencedCount (int) - ReferencedSizeBytes (long) @@ -59,7 +60,7 @@ function Get-OrphanedInstallerFiles { param() $startTime = Get-Date - $errors = @() + $registryErrors = @() # --- STEP 1: Build the "referenced files" set --- # The Windows Installer stores product/patch cache file references in the registry. @@ -89,7 +90,7 @@ function Get-OrphanedInstallerFiles { } } } catch { - $errors += "Product $($product.PSChildName): $($_.Exception.Message)" + $registryErrors += "Product $($product.PSChildName): $($_.Exception.Message)" } } } @@ -110,7 +111,7 @@ function Get-OrphanedInstallerFiles { [void]$referencedFiles.Add($localPackage) } } catch { - $errors += "Patch $($patch.PSChildName): $($_.Exception.Message)" + $registryErrors += "Patch $($patch.PSChildName): $($_.Exception.Message)" } } } @@ -121,25 +122,28 @@ function Get-OrphanedInstallerFiles { # --- STEP 2: Enumerate actual files in C:\Windows\Installer --- # Top-level only — do NOT include $PatchCache$ subfolder contents $installerPath = "C:\Windows\Installer" - $allFiles = Get-ChildItem $installerPath -File -Force -ErrorAction SilentlyContinue | - Where-Object { $_.Extension -in '.msi', '.msp' } + # Enumerate all files first (unfiltered) for accurate folder-total metric, then filter for orphan detection. + # This avoids misleading operators when comparing script output against Explorer-reported folder sizes. + $allInstallerFiles = Get-ChildItem $installerPath -File -Force -ErrorAction SilentlyContinue + $allFiles = $allInstallerFiles | Where-Object { $_.Extension -in '.msi', '.msp' } # --- STEP 3: Compare and classify --- - $referenced = @() - $orphaned = @() + # Use List instead of @() += to avoid O(n^2) array reallocation on machines with thousands of files + $referenced = [System.Collections.Generic.List[PSCustomObject]]::new() + $orphaned = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($file in $allFiles) { if ($referencedFiles.Contains($file.FullName)) { - $referenced += [PSCustomObject]@{ + $referenced.Add([PSCustomObject]@{ FullPath = $file.FullName SizeBytes = $file.Length - } + }) } else { - $orphaned += [PSCustomObject]@{ + $orphaned.Add([PSCustomObject]@{ FullPath = $file.FullName SizeBytes = $file.Length LastWriteTime = $file.LastWriteTime - } + }) } } @@ -153,19 +157,22 @@ function Get-OrphanedInstallerFiles { } # --- STEP 5: Return results --- - # Null-coerce all Measure-Object .Sum results — .Sum returns $null on empty collections + # Null-coerce all Measure-Object .Sum results — .Sum returns $null on empty collections. + # InstallerFolderTotalBytes = unfiltered folder total (matches Explorer-reported size). + # TotalFiles/TotalSizeBytes = MSI/MSP-only subset used for orphan detection. [PSCustomObject]@{ - TotalFiles = ($allFiles | Measure-Object).Count -as [int] - TotalSizeBytes = ($allFiles | Measure-Object Length -Sum).Sum -as [long] - ReferencedFiles = $referenced - ReferencedCount = $referenced.Count - ReferencedSizeBytes = ($referenced | Measure-Object SizeBytes -Sum).Sum -as [long] - OrphanedFiles = $orphaned - OrphanedCount = $orphaned.Count - OrphanedSizeBytes = ($orphaned | Measure-Object SizeBytes -Sum).Sum -as [long] - PatchCacheSizeBytes = $patchCacheSize - ScanDuration = (Get-Date) - $startTime - Errors = $errors + InstallerFolderTotalBytes = ($allInstallerFiles | Measure-Object Length -Sum).Sum -as [long] + TotalFiles = ($allFiles | Measure-Object).Count -as [int] + TotalSizeBytes = ($allFiles | Measure-Object Length -Sum).Sum -as [long] + ReferencedFiles = $referenced + ReferencedCount = $referenced.Count + ReferencedSizeBytes = ($referenced | Measure-Object SizeBytes -Sum).Sum -as [long] + OrphanedFiles = $orphaned + OrphanedCount = $orphaned.Count + OrphanedSizeBytes = ($orphaned | Measure-Object SizeBytes -Sum).Sum -as [long] + PatchCacheSizeBytes = $patchCacheSize + ScanDuration = (Get-Date) - $startTime + Errors = $registryErrors } } @@ -177,6 +184,7 @@ Write-Output "Timestamp: $(Get-Date -Format 'o')" Write-Output "" $status = "Healthy" +$installerFolderSizeGB = 0 $totalSizeGB = 0 $orphanedSizeGB = 0 $orphanedCount = 0 @@ -192,6 +200,9 @@ try { # --- Run orphan detection --- $results = Get-OrphanedInstallerFiles + # InstallerFolderTotalBytes = unfiltered folder total (all file types, matches Explorer) + # TotalSizeBytes = MSI/MSP-only subset (what orphan detection covers) + $installerFolderSizeGB = [math]::Round($results.InstallerFolderTotalBytes / 1GB, 2) $totalSizeGB = [math]::Round($results.TotalSizeBytes / 1GB, 2) $orphanedSizeGB = [math]::Round($results.OrphanedSizeBytes / 1GB, 2) $orphanedCount = $results.OrphanedCount @@ -203,19 +214,26 @@ try { $scanErrors = $results.Errors # --- Measure WinSxS size (secondary indicator) --- - # NOTE: WinSxS uses hardlinks extensively. Recursive enumeration overcounts actual disk - # footprint by 2-3x because the same physical data is counted per hardlink. This value - # is a rough indicator, not an exact measurement. Use DISM /AnalyzeComponentStore for - # precise sizing if needed. - $winsxsSize = (Get-ChildItem "C:\Windows\WinSxS" -Recurse -Force -ErrorAction SilentlyContinue | - Measure-Object Length -Sum).Sum -as [long] - if (-not $winsxsSize) { $winsxsSize = [long]0 } + # NOTE: WinSxS uses hardlinks extensively — enumeration overcounts actual disk footprint + # by 2-3x. This is a rough indicator only. Use DISM /AnalyzeComponentStore for precise sizing. + # Uses .NET EnumerateFiles (lazy iterator) instead of Get-ChildItem -Recurse to avoid + # blocking the script for 2-10+ minutes on HDD-backed or heavily-loaded servers. + try { + $winsxsSize = [long]0 + foreach ($f in [System.IO.Directory]::EnumerateFiles("C:\Windows\WinSxS", "*", [System.IO.SearchOption]::AllDirectories)) { + try { $winsxsSize += ([System.IO.FileInfo]::new($f)).Length } catch { } + } + } catch { + $winsxsSize = [long]0 + } $winsxsSizeGB = [math]::Round($winsxsSize / 1GB, 2) # --- Determine status based on total Installer folder size --- - if ($totalSizeGB -ge $CriticalThresholdGB) { + # Uses the unfiltered folder total (InstallerFolderTotalBytes) for threshold comparison + # so status matches what operators see in Explorer + if ($installerFolderSizeGB -ge $CriticalThresholdGB) { $status = "Critical" - } elseif ($totalSizeGB -ge $WarningThresholdGB) { + } elseif ($installerFolderSizeGB -ge $WarningThresholdGB) { $status = "Warning" } else { $status = "Healthy" @@ -230,10 +248,10 @@ try { # WRITE NINJARMM CUSTOM FIELDS # ============================================================================ try { - Ninja-Property-Set installerFolderSizeGB ([math]::Round($totalSizeGB, 2)) - Ninja-Property-Set installerOrphanedSizeGB ([math]::Round($orphanedSizeGB, 2)) + Ninja-Property-Set installerFolderSizeGB $installerFolderSizeGB + Ninja-Property-Set installerOrphanedSizeGB $orphanedSizeGB Ninja-Property-Set installerOrphanedCount $orphanedCount - Ninja-Property-Set installerWinSxSSizeGB ([math]::Round($winsxsSizeGB, 2)) + Ninja-Property-Set installerWinSxSSizeGB $winsxsSizeGB Ninja-Property-Set installerStatus $status Ninja-Property-Set installerLastScan (Get-Date -Format "o") } catch { @@ -263,6 +281,7 @@ $eventId = switch ($status) { "Warning" { 2000 } "Critical" { 2500 } "Error" { 3000 } + default { 3000 } } # Map EntryType to actual severity so SIEM/monitoring tools can filter $entryType = switch ($status) { @@ -270,11 +289,13 @@ $entryType = switch ($status) { "Warning" { "Warning" } "Critical" { "Error" } "Error" { "Error" } + default { "Error" } } $message = @" DTC Installer Patch Monitor — Scan Complete Status: $status -Total Installer Folder: $totalSizeGB GB +Installer Folder Total: $installerFolderSizeGB GB +MSI/MSP Files: $totalFiles ($totalSizeGB GB) Orphaned Files: $orphanedCount ($orphanedSizeGB GB) Referenced Files: $referencedCount ($referencedSizeGB GB) PatchCache: $patchCacheSizeGB GB @@ -295,9 +316,10 @@ try { # ============================================================================ Write-Output "=== SCAN RESULTS ===" Write-Output "Status: $status" -Write-Output "Installer Folder: $totalSizeGB GB ($totalFiles files)" -Write-Output " Referenced: $referencedSizeGB GB ($referencedCount files)" -Write-Output " Orphaned: $orphanedSizeGB GB ($orphanedCount files)" +Write-Output "Installer Folder: $installerFolderSizeGB GB" +Write-Output " MSI/MSP Files: $totalSizeGB GB ($totalFiles files)" +Write-Output " Referenced: $referencedSizeGB GB ($referencedCount files)" +Write-Output " Orphaned: $orphanedSizeGB GB ($orphanedCount files)" Write-Output " PatchCache: $patchCacheSizeGB GB" Write-Output "WinSxS: $winsxsSizeGB GB (approximate — hardlink overcount)" Write-Output "Scan Duration: $scanDurationSec seconds" From 96e157ac2ea6b8606563f45e0fe8ed77e1d32fca Mon Sep 17 00:00:00 2001 From: Zach Boogher <129975920+AlrightLad@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:02:04 -0500 Subject: [PATCH 4/7] Refactor InstallerFolderTotalBytes calculation Updated InstallerFolderTotalBytes calculation to include $PatchCache$ size and improved path handling for Windows directories. --- rmm-ninja/Monitor-InstallerPatches | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/rmm-ninja/Monitor-InstallerPatches b/rmm-ninja/Monitor-InstallerPatches index 4f71c1f..139eb35 100644 --- a/rmm-ninja/Monitor-InstallerPatches +++ b/rmm-ninja/Monitor-InstallerPatches @@ -43,7 +43,7 @@ function Get-OrphanedInstallerFiles { Uses registry queries only. .OUTPUTS PSCustomObject with properties: - - InstallerFolderTotalBytes (long) — unfiltered folder total (all file types) + - InstallerFolderTotalBytes (long) — folder total incl. $PatchCache$ (matches Explorer) - TotalFiles (int) — MSI/MSP files only - TotalSizeBytes (long) — MSI/MSP files only - ReferencedFiles (array of PSCustomObject: FullPath, SizeBytes) @@ -121,7 +121,7 @@ function Get-OrphanedInstallerFiles { # --- STEP 2: Enumerate actual files in C:\Windows\Installer --- # Top-level only — do NOT include $PatchCache$ subfolder contents - $installerPath = "C:\Windows\Installer" + $installerPath = Join-Path $env:SystemRoot "Installer" # Enumerate all files first (unfiltered) for accurate folder-total metric, then filter for orphan detection. # This avoids misleading operators when comparing script output against Explorer-reported folder sizes. $allInstallerFiles = Get-ChildItem $installerPath -File -Force -ErrorAction SilentlyContinue @@ -158,10 +158,11 @@ function Get-OrphanedInstallerFiles { # --- STEP 5: Return results --- # Null-coerce all Measure-Object .Sum results — .Sum returns $null on empty collections. - # InstallerFolderTotalBytes = unfiltered folder total (matches Explorer-reported size). + # InstallerFolderTotalBytes = top-level files + $PatchCache$ (matches Explorer-reported size). # TotalFiles/TotalSizeBytes = MSI/MSP-only subset used for orphan detection. + $topLevelTotal = ($allInstallerFiles | Measure-Object Length -Sum).Sum -as [long] [PSCustomObject]@{ - InstallerFolderTotalBytes = ($allInstallerFiles | Measure-Object Length -Sum).Sum -as [long] + InstallerFolderTotalBytes = $topLevelTotal + $patchCacheSize TotalFiles = ($allFiles | Measure-Object).Count -as [int] TotalSizeBytes = ($allFiles | Measure-Object Length -Sum).Sum -as [long] ReferencedFiles = $referenced @@ -220,7 +221,8 @@ try { # blocking the script for 2-10+ minutes on HDD-backed or heavily-loaded servers. try { $winsxsSize = [long]0 - foreach ($f in [System.IO.Directory]::EnumerateFiles("C:\Windows\WinSxS", "*", [System.IO.SearchOption]::AllDirectories)) { + $winsxsPath = Join-Path $env:SystemRoot "WinSxS" + foreach ($f in [System.IO.Directory]::EnumerateFiles($winsxsPath, "*", [System.IO.SearchOption]::AllDirectories)) { try { $winsxsSize += ([System.IO.FileInfo]::new($f)).Length } catch { } } } catch { From 29017bb8f91fca4ff6f7f93ef81f0b97e37609ef Mon Sep 17 00:00:00 2001 From: Zach Boogher <129975920+AlrightLad@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:23:45 -0500 Subject: [PATCH 5/7] Refactor error handling to use List for efficiency Updated error handling to use List for registry errors and scan errors, improving performance and readability. --- rmm-ninja/Monitor-InstallerPatches | 90 ++++++++++++++++-------------- 1 file changed, 48 insertions(+), 42 deletions(-) diff --git a/rmm-ninja/Monitor-InstallerPatches b/rmm-ninja/Monitor-InstallerPatches index 139eb35..afeb0b8 100644 --- a/rmm-ninja/Monitor-InstallerPatches +++ b/rmm-ninja/Monitor-InstallerPatches @@ -60,63 +60,69 @@ function Get-OrphanedInstallerFiles { param() $startTime = Get-Date - $registryErrors = @() + $registryErrors = [System.Collections.Generic.List[string]]::new() # --- STEP 1: Build the "referenced files" set --- - # The Windows Installer stores product/patch cache file references in the registry. - # Products: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Products\{GUID}\InstallProperties - # → "LocalPackage" value = path to cached .msi file - # Patches: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Patches\{GUID} - # → "LocalPackage" value = path to cached .msp file + # The Windows Installer stores product/patch cache file references in the registry under + # HKLM:\...\Installer\UserData\\Products and \Patches for each SID. + # Enumerate ALL SIDs (not just S-1-5-18) to capture per-user installations that also + # reference files in C:\Windows\Installer. Missing per-user SIDs inflates orphan counts. # - # NOTE: Registry GUIDs are in "compressed" format (no dashes, no braces, character reordering). - # However, the LocalPackage values contain standard file paths — we do NOT need to decompress - # GUIDs. We just collect the LocalPackage values directly. + # LocalPackage values contain standard file paths — no GUID decompression needed. $referencedFiles = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) - # Collect referenced MSI files from Products - $productsPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Products" + $userDataPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData" try { - if (Test-Path $productsPath) { - $productGuids = Get-ChildItem $productsPath -ErrorAction Stop - foreach ($product in $productGuids) { - try { - $installProps = Join-Path $product.PSPath "InstallProperties" - if (Test-Path $installProps) { - $localPackage = (Get-ItemProperty $installProps -Name "LocalPackage" -ErrorAction SilentlyContinue).LocalPackage - if ($localPackage -and (Test-Path $localPackage)) { - [void]$referencedFiles.Add($localPackage) + if (-not (Test-Path $userDataPath)) { + throw "Installer UserData registry path not found" + } + $sidKeys = Get-ChildItem $userDataPath -ErrorAction Stop + } catch { + throw "Failed to read Installer UserData registry path: $($_.Exception.Message)" + } + + foreach ($sidKey in $sidKeys) { + # Collect referenced MSI files from Products + $productsPath = Join-Path $sidKey.PSPath "Products" + try { + if (Test-Path $productsPath) { + foreach ($product in (Get-ChildItem $productsPath -ErrorAction Stop)) { + try { + $installProps = Join-Path $product.PSPath "InstallProperties" + if (Test-Path $installProps) { + $localPackage = (Get-ItemProperty $installProps -Name "LocalPackage" -ErrorAction SilentlyContinue).LocalPackage + if ($localPackage -and (Test-Path $localPackage)) { + [void]$referencedFiles.Add($localPackage) + } } + } catch { + $registryErrors.Add("Product $($product.PSChildName) (SID $($sidKey.PSChildName)): $($_.Exception.Message)") } - } catch { - $registryErrors += "Product $($product.PSChildName): $($_.Exception.Message)" } } + } catch { + $registryErrors.Add("Products key for SID $($sidKey.PSChildName): $($_.Exception.Message)") } - } catch { - # If we can't read the Products key at all, this is a critical failure - throw "Failed to read Products registry path: $($_.Exception.Message)" - } - # Collect referenced MSP files from Patches - $patchesPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Patches" - try { - if (Test-Path $patchesPath) { - $patchGuids = Get-ChildItem $patchesPath -ErrorAction Stop - foreach ($patch in $patchGuids) { - try { - $localPackage = (Get-ItemProperty $patch.PSPath -Name "LocalPackage" -ErrorAction SilentlyContinue).LocalPackage - if ($localPackage -and (Test-Path $localPackage)) { - [void]$referencedFiles.Add($localPackage) + # Collect referenced MSP files from Patches + $patchesPath = Join-Path $sidKey.PSPath "Patches" + try { + if (Test-Path $patchesPath) { + foreach ($patch in (Get-ChildItem $patchesPath -ErrorAction Stop)) { + try { + $localPackage = (Get-ItemProperty $patch.PSPath -Name "LocalPackage" -ErrorAction SilentlyContinue).LocalPackage + if ($localPackage -and (Test-Path $localPackage)) { + [void]$referencedFiles.Add($localPackage) + } + } catch { + $registryErrors.Add("Patch $($patch.PSChildName) (SID $($sidKey.PSChildName)): $($_.Exception.Message)") } - } catch { - $registryErrors += "Patch $($patch.PSChildName): $($_.Exception.Message)" } } + } catch { + $registryErrors.Add("Patches key for SID $($sidKey.PSChildName): $($_.Exception.Message)") } - } catch { - throw "Failed to read Patches registry path: $($_.Exception.Message)" } # --- STEP 2: Enumerate actual files in C:\Windows\Installer --- @@ -195,7 +201,7 @@ $patchCacheSizeGB = 0 $winsxsSizeGB = 0 $totalFiles = 0 $scanDurationSec = 0 -$scanErrors = @() +$scanErrors = [System.Collections.Generic.List[string]]::new() try { # --- Run orphan detection --- @@ -242,7 +248,7 @@ try { } } catch { $status = "Error" - $scanErrors += "Critical scan failure: $($_.Exception.Message)" + $scanErrors.Add("Critical scan failure: $($_.Exception.Message)") Write-Error "Scan failed: $($_.Exception.Message)" } From 47d56c5907e3f1e1e0a6c22f0cce148e42afd0c5 Mon Sep 17 00:00:00 2001 From: Zach Boogher <129975920+AlrightLad@users.noreply.github.com> Date: Thu, 19 Feb 2026 18:24:07 -0500 Subject: [PATCH 6/7] Clarify event ID comments for monitoring tool Updated comments for clarity on event ID usage. --- rmm-ninja/Monitor-InstallerPatches | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/rmm-ninja/Monitor-InstallerPatches b/rmm-ninja/Monitor-InstallerPatches index afeb0b8..86c196e 100644 --- a/rmm-ninja/Monitor-InstallerPatches +++ b/rmm-ninja/Monitor-InstallerPatches @@ -283,7 +283,12 @@ try { Write-Warning "Event source registration skipped: $($_.Exception.Message)" } -# Distinct Event IDs per severity for monitoring tool granularity +# Distinct Event IDs per severity — intentionally separated so SIEM rules can +# target each level independently: +# Healthy = 1000 (Information) +# Warning = 2000 (Warning) +# Critical = 2500 (Error) +# Error = 3000 (Error) $eventId = switch ($status) { "Healthy" { 1000 } "Warning" { 2000 } From f45108df24d1dc1fc7f67a393816d22066959b72 Mon Sep 17 00:00:00 2001 From: Zach Boogher <129975920+AlrightLad@users.noreply.github.com> Date: Thu, 19 Feb 2026 18:54:55 -0500 Subject: [PATCH 7/7] Refactor LocalPackage checks and improve error handling --- rmm-ninja/Monitor-InstallerPatches | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/rmm-ninja/Monitor-InstallerPatches b/rmm-ninja/Monitor-InstallerPatches index 86c196e..07692ce 100644 --- a/rmm-ninja/Monitor-InstallerPatches +++ b/rmm-ninja/Monitor-InstallerPatches @@ -69,6 +69,8 @@ function Get-OrphanedInstallerFiles { # reference files in C:\Windows\Installer. Missing per-user SIDs inflates orphan counts. # # LocalPackage values contain standard file paths — no GUID decompression needed. + # Test-Path is intentionally omitted: the HashSet is only compared against Get-ChildItem + # output (guaranteed-existing files), so stale registry paths are harmless dead weight. $referencedFiles = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) @@ -92,7 +94,7 @@ function Get-OrphanedInstallerFiles { $installProps = Join-Path $product.PSPath "InstallProperties" if (Test-Path $installProps) { $localPackage = (Get-ItemProperty $installProps -Name "LocalPackage" -ErrorAction SilentlyContinue).LocalPackage - if ($localPackage -and (Test-Path $localPackage)) { + if ($localPackage) { [void]$referencedFiles.Add($localPackage) } } @@ -112,7 +114,7 @@ function Get-OrphanedInstallerFiles { foreach ($patch in (Get-ChildItem $patchesPath -ErrorAction Stop)) { try { $localPackage = (Get-ItemProperty $patch.PSPath -Name "LocalPackage" -ErrorAction SilentlyContinue).LocalPackage - if ($localPackage -and (Test-Path $localPackage)) { + if ($localPackage) { [void]$referencedFiles.Add($localPackage) } } catch { @@ -232,7 +234,9 @@ try { try { $winsxsSize += ([System.IO.FileInfo]::new($f)).Length } catch { } } } catch { - $winsxsSize = [long]0 + # Preserve partial accumulation — $winsxsSize retains bytes counted before the + # iterator error. The value is already documented as approximate (hardlink overcount). + Write-Warning "WinSxS enumeration interrupted (partial result: $([math]::Round($winsxsSize/1GB,2)) GB): $($_.Exception.Message)" } $winsxsSizeGB = [math]::Round($winsxsSize / 1GB, 2)