PSProfileWatcher.psm1
|
<# .SYNOPSIS Registers the PSProfileWatcher check for the current user's profile. .DESCRIPTION Registers the PSProfileWatcher check for the current user's profile. .INPUTS None .OUTPUTS None .EXAMPLE Register-PSProfileCheck .NOTES None #> function Add-PSProfileCheck { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param() <# .SYNOPSIS Adds a call to Test-PSProfileHash at the beginning of the current user's profile. .DESCRIPTION Inserts a call to Test-PSProfileHash at the very beginning of the current user's PowerShell profile so that the hash check runs before any other profile code. .INPUTS None .OUTPUTS None .EXAMPLE Add-PSProfileCheck Adds the following line to the beginning of the user's profile: `Test-PSProfileHash` .NOTES None #> # Block to insert at top of profile: import module then call the check $profileCheckBlock = "Import-Module PSProfileWatcher -ErrorAction SilentlyContinue`nTest-PSProfileHash" try { # Read the current profile content $profileContent = Get-Content -Path $profile -Raw -ErrorAction Stop } catch { Write-Error "❌ Could not read profile at '$profile'." throw $_.Exception.Message } # Check if the check (or the import+call) is already in the profile if ($profileContent -match 'Test-PSProfileHash' -or $profileContent -match 'Import-Module\s+PSProfileWatcher') { Write-Host "⚠️ Profile check or module import already exists in profile." -ForegroundColor Yellow return } # Add the import+call block to the beginning of the profile $newProfileContent = "$profileCheckBlock`n`n$profileContent" $actionTarget = "$profile" if ($PSCmdlet.ShouldProcess($actionTarget, 'Add PSProfileWatcher check')) { try { $newProfileContent | Out-File -FilePath $profile -Encoding UTF8 -Force -ErrorAction Stop Write-Host "✅ Profile check added successfully to the beginning of '$profile'." -ForegroundColor Green } catch { Write-Error "❌ Could not write profile check to file." throw $_.Exception.Message } } else { continue } } <# .SYNOPSIS Saves the current user's profile hash for comparison at next run. .DESCRIPTION Saves the current user's profile hash for comparison at next run. .INPUTS None .OUTPUTS None .EXAMPLE Export-PSProfileHash Writes the following files to the current user's profile directory (e.g.): - Microsoft.VSCode_profile.ps1_83580fb6-fc89-4756-8a58-b1f4d3f3b7ff.hash - Microsoft.VSCode_profile.ps1_83580fb6-fc89-4756-8a58-b1f4d3f3b7ff.backup The hash file contains the SHA256 hash of the current user's profile at that point. The backup file contains a copy of the current user's profile at that point. .NOTES None #> function Export-PSProfileHash { $guid = [guid]::NewGuid() $profileHashFile = "$($profile)_$($guid).hash" $profileBackupFile = "$($profile)_$($guid).backup" try { $Hash = Get-FileHash -Path $profile -Algorithm SHA256 -ErrorAction Stop | Select-Object -ExpandProperty Hash } catch { Write-Warning "⚠️ Could not calculate hash for profile." throw $_.Exception.Message } try { $Hash | Out-File -FilePath $profileHashFile -Encoding ASCII -Force -ErrorAction Stop Write-Host "✅ Profile hash saved successfully to '$profileHashFile'." -ForegroundColor Green } catch { Write-Error "❌ Could not write hash to file." throw $_.Exception.Message } try { Copy-Item -Path $profile -Destination $profileBackupFile -Force -ErrorAction Stop Write-Host "✅ Profile backup saved successfully to '$profileBackupFile'." -ForegroundColor Green } catch { Write-Error "❌ Could not save profile backup to '$profileBackupFile'." throw $_.Exception.Message } } function Remove-PSProfileCheck { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param() <# .SYNOPSIS Removes the Test-PSProfileHash call from the current user's profile. .DESCRIPTION Removes the Test-PSProfileHash function call that was added to the beginning of the current user's PowerShell profile by Add-PSProfileCheck. .INPUTS None .OUTPUTS None .EXAMPLE Remove-PSProfileCheck Removes the Test-PSProfileHash call from the user's profile. .NOTES None #> # Block components to remove from the profile: import statement and the check call $importLinePattern = 'Import-Module\s+PSProfileWatcher(?:\s+-ErrorAction\s+\w+)?' $callLinePattern = 'Test-PSProfileHash' try { # Read the current profile content $profileContent = Get-Content -Path $profile -Raw -ErrorAction Stop } catch { Write-Error "❌ Could not read profile at '$profile'." throw $_.Exception.Message } # Check if the check exists in the profile if ($profileContent -notmatch $callLinePattern -and $profileContent -notmatch $importLinePattern) { Write-Host "⚠️ Profile check does not exist in profile." -ForegroundColor Yellow return } # Remove the import line and the check call (and any immediate blank lines) # Use the pattern variables (not literal strings) when calling -replace so the regex is applied $newProfileContent = $profileContent -replace ($importLinePattern + '\r?\n?'), '' $newProfileContent = $newProfileContent -replace ($callLinePattern + '\r?\n\s*'), '' # Trim any leading blank lines that may remain after removal $newProfileContent = $newProfileContent.TrimStart("`r", "`n") $actionTarget = "$profile" if ($PSCmdlet.ShouldProcess($actionTarget, 'Remove PSProfileWatcher check')) { try { $newProfileContent | Out-File -FilePath $profile -Encoding UTF8 -Force -ErrorAction Stop Write-Host "✅ Profile check removed successfully from '$profile'." -ForegroundColor Green } catch { Write-Error "❌ Could not write to profile file." throw $_.Exception.Message } } else { continue } } function Test-PSProfileHash { <# .SYNOPSIS Test the current user's profile hash against the expected stored hash .DESCRIPTION Test the current user's profile hash against the expected stored hash. This function is designed to be called at the beginning of your PowerShell profile to detect if the profile file has been modified since the last hash export. .INPUTS None .OUTPUTS System.Boolean Returns $true if the profile hash matches the stored hash, $false otherwise. .EXAMPLE Test-PSProfileHash Compares the current profile's hash against the most recently saved hash file. .NOTES None #> try { $profilesPath = Split-Path -Path $profile -Parent $latestHashFile = Get-ChildItem -Path $profilesPath -Filter "*.hash" -ErrorAction Stop | Sort-Object -Property CreationTime | Select-Object -Last 1 if (-not $latestHashFile) { Write-Warning "⚠️ No profile hash file found. Run 'Export-PSProfileHash' first." } $lastProfileHash = Get-Content -Path $latestHashFile.FullName -ErrorAction Stop } catch { Write-Error "❌ Could not read stored profile hash." throw $_.Exception.Message } try { $currentProfileHash = Get-FileHash -Path $profile -Algorithm SHA256 -ErrorAction Stop | Select-Object -ExpandProperty Hash } catch { Write-Error "❌ Could not calculate current profile hash." throw $_.Exception.Message } if ($lastProfileHash -eq $currentProfileHash) { Write-Host "✅ Profile hash matches." -ForegroundColor Green } else { try { Write-Host @" ██████╗ ███████╗██████╗ ██████╗ ██████╗ ███████╗██╗██╗ ███████╗██╗ ██╗ █████╗ ████████╗ ██████╗██╗ ██╗███████╗██████╗ ██╔══██╗██╔════╝██╔══██╗██╔══██╗██╔═══██╗██╔════╝██║██║ ██╔════╝██║ ██║██╔══██╗╚══██╔══╝██╔════╝██║ ██║██╔════╝██╔══██╗ ██████╔╝███████╗██████╔╝██████╔╝██║ ██║█████╗ ██║██║ █████╗ ██║ █╗ ██║███████║ ██║ ██║ ███████║█████╗ ██████╔╝ ██╔═══╝ ╚════██║██╔═══╝ ██╔══██╗██║ ██║██╔══╝ ██║██║ ██╔══╝ ██║███╗██║██╔══██║ ██║ ██║ ██╔══██║██╔══╝ ██╔══██╗ ██║ ███████║██║ ██║ ██║╚██████╔╝██║ ██║███████╗███████╗╚███╔███╔╝██║ ██║ ██║ ╚██████╗██║ ██║███████╗██║ ██║ "@ Write-Host "`nProfile hash does not match; verify these changes are expected and if so, run Export-PSProfileHash to update the stored hash." -ForegroundColor Yellow Write-Host "`nIf you did not expect these changes, abort loading the profile and remove unexpected changes prior to next load." -ForegroundColor Yellow $latestBackupFile = Get-ChildItem -Path $profilesPath -Filter "*.backup" -ErrorAction Stop | Sort-Object -Property CreationTime | Select-Object -Last 1 if ($latestBackupFile) { $lastProfileContent = Get-Content -Path $latestBackupFile.FullName -ErrorAction Stop $currentProfileContent = Get-Content -Path $profile -ErrorAction Stop Write-Host "`n----- Profile Changes -----" -ForegroundColor Yellow Compare-Object -ReferenceObject $lastProfileContent -DifferenceObject $currentProfileContent | Out-String | Write-Host } $userInput = Read-Host "Do you want to continue loading the profile? (y/N)" switch ( $userInput ) { 'y' { continue } 'Y' { continue } default { Write-Host "Aborting profile load." -ForegroundColor Red exit } } } catch { Write-Warning "⚠️ Could not display profile differences." } } } Export-ModuleMember -Function * |