Securely Synchronizing PowerShell Profiles Across Multiple Computers

I’ve been making frequent changes to my PowerShell profiles and I’m finding it difficult to keep them synchronized across various systems - home PC, home laptop, various lab VMs, some of which are local, some not. I looked online to see what others were doing and most of what I saw involved storing the profile scripts in Dropbox/OneDrive/Google Drive/whatever and creating a symbolic link or dotsourcing.

I don’t particularly like this method as it would require me to have the client installed, which is a deal breaker for lab VMs or machines I don’t use frequently. Further, none of these products are particularly well suited at storing code, and I wasn’t comfortable with the level of security provided. Symbolic linking a cloud hosted file leaves you very vulnerable should your account get compromised.

So I decided to design my own solution - one that stores the profile in version control. This will give us a few additional security benefits as we’ll see later in the post.

The following was written specifically for GitHub users, but I’m confident the same approach would work with GitLab or other version control providers.

A quick refresher on PowerShell profiles

A PowerShell profile is nothing more than than a PowerShell script stored in a specific location which is executed each time a PowerShell prompt is opened. We can add miscellaneous functions to our profiles that make commonly performed tasks easier and quicker. See my other posts on customising the PowerShell window title and Expanding Shortened URLs for a few examples of where profiles can be useful.

Different versions of PowerShell have different profile locations:

  • PowerShell 5.1: %USERPROFILE%\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1
  • PowerShell 7: %USERPROFILE%\Documents\PowerShell\Microsoft.PowerShell_profile.ps1

If you have not made changes to your profile in the past, the file(s) will need to be created.

More detailed information can be found in the documentation.

Security implications

Let’s step back for a second and talk about the security implications of storing PowerShell profiles on remote systems. Profile scripts are automatically executed each time PowerShell is opened, which means if someone managed to get write access to wherever you profile script is stored, they get run whatever commands they want on your machine(s).

With a locally stored profile this isn’t as much of a concern, someone needs to already have access to your machine in order to modify it. What we’re effectively doing by storing our profiles remotely is creating a new attack vector.

You will need to make a call on whether that’s something you’re comfortable with.

This applies to any method of storing your PowerShell profile remotely, be it my solution, or Dropbox/etc., or a network share.

Basic security measures

As we’re going to be storing our profile in GitHub, there are few security measures we can implement on their platform.

  1. Use a long and unique password for your GitHub account
  2. Enable 2FA

There will be some additional security we’ll bake into our scripts, but above are a must if you intend to do this.

Now that the GitHub side is secured, let’s move on.

Storing the PowerShell profile on Github

I chose to store my profile in a Gist instead of a repository. I felt a repo would be overkill as in order to make it ‘secret’ I would need to deal with authentication, including storing API keys and such on each client, which is too much work and not something I’m fond of doing.

Editing either a Gist or repository file requires authenticating to GitHub, so I don’t think security is much improved by using a repository, and I’m not storing anything sensitive in the profile that would be a concern if it was found.

In short, to begin, make a secret (if you choose) Gist, and put whatever code you wish to have present in your PowerShell profile. I’ll start off simple with a function to return my WAN IP.

# Return WAN IP
function WANIP {
    Invoke-RestMethod http://ifconfig.me/ip
}

We also need to ensure the filename is set to PSProfile with no extension as this will be referenced in our client side script.

Creating a Gist

Once we create the Gist we’ll get a URL with the GistId i.e., https://gist.github.com/mdjx/0153ae2b69f87adcacb8c677c4c4a5a6, the 0153ae2b69f87adcacb8c677c4c4a5a6 part being the Id - we’re going to need this.

The next step is to pull down the code in our Gist, using the GitHub public API, and inject it into our profile.

Retrieving the Gist and adding it to our PowerShell profile

GitHub has a great public API for Gists, which is documented here.

If all we want to do is pull down our code and inject the stored functions into our profiles, it’s as simple as making a single call the API and executing the saved functions.

Invoke-Expression (Invoke-RestMethod https://api.github.com/gists/0153ae2b69f87adcacb8c677c4c4a5a6).files.PSProfile.content

This tells PowerShell to retrieve our Gist using the API, drill down to the PSProfile file, and retrieve the contents which are then executed by Invoke-Expression.

There are a few things to note in the API call URL, it contains

  1. The Gist Id (0153ae2b69f87adcacb8c677c4c4a5a6), and
  2. The filename we defined when creating the Gist (PSProfile)

The API call tool less than half a second, so I’m not worried about it causing PowerShell to be even slower to open.

PS C:\> (Measure-Command {(Invoke-RestMethod https://api.github.com/gists/0153ae2b69f87adcacb8c677c4c4a5a6).files.PSProfile.content}).TotalSeconds
0.0452786

If we add the above code to our PowerShell profiles, it will load the latest version of our Gist each time we open a prompt.

While this works, it does leave us a little open. We have no way to know whether our GitHub account was somehow compromised and the Gist was modified since we last loaded PowerShell.

GitHub tracks changes to a Gist just like commits to a normal repository (a Gist is actually a proper repository with special rules), and we can use these as changes to inform us when an update has been made.

Adding client side security

Despite having our GitHub account secured with a good password and 2FA I would feel more comfortable with a client side check so supplement those. If we’re notified of a change in the Gist that we need to approve before the profile is loaded, and we haven’t made any changes, that is a definite red flag.

The goal is to implement a system where as a profile is being loaded, two locally stored values are compared to what is provided by the GitHub API

  1. The updated_at timestamp, and
  2. The latest revision’s SHA hash

If both values match, we can be confident the remote file is the same as one that was previously loaded, and we can proceed.

Otherwise, if there is a mismatch, or the local values aren’t present, it means either the Gist has changed, or we’re pulling the profile down for the first time and don’t have those values stored locally.

Further, if the Gist has changed, we want to be able to see the changes, and they would need to be approved prior to being loaded, at which point the local values are also updated to reflect the new timestamp and hash.

Finally, we want to cache a copy of the last approved profile, in case we’re in a place with no Internet access.

Let’s implement this by adding the following to our local PowerShell profile - this will replace the prior Invoke-Expression command.

# Define Gist Id which contains our profile script
$ProfileGistID = '0153ae2b69f87adcacb8c677c4c4a5a6'

function VerifyRemoteProfile {

    Param(
        [Parameter(Mandatory=$true)]
        [psobject]$Gist,
        [Parameter(Mandatory=$true)]
        [string]$PSCachedProfile
    )

    # Define possible profile loading options
    Add-Type -TypeDefinition @"
        public enum ProfileLoadOption {
            LoadCached,
            LoadRemote
         }
"@

    # Set default to load locally cached profile
    $LoadProfile = [ProfileLoadOption]::LoadCached

    $PSRemoteProfileVersionFile = ([System.IO.FileInfo]$profile).DirectoryName + "\PSRemoteProfileVersions.json"

    # Load current profile versions or create new instance if none are present
    try {
        $VersionData = Get-Content $PSRemoteProfileVersionFile -Raw -ErrorAction Stop | ConvertFrom-Json
        $NewFile = $false
    }
    catch {
        Write-Host "No profile version file found, creating..."
        $VersionData = New-Object -TypeName PSObject -Property @{
            LastModified = Get-Date -Year 1900 -Format u
            LastCommitHash = "None"
        }
        $NewFile = $true
    }

    # Loading Gist data
    $LastModified = $Gist.updated_at
    $LastCommitHash = $Gist.history[0].version

    # Request approval if remote profile has changed, otherwise load cached version
    if (($VersionData.LastModified -ne $LastModified) -or ($VersionData.LastCommitHash -ne $LastCommitHash)) {
        Write-Host "-----------------------------------------"
        Write-Host "Local Last Modified timestamp is $(([datetime]$VersionData.LastModified).ToLocalTime().ToString()), remote is $(([datetime]$LastModified).ToLocalTime().ToString())"
        Write-Host "Local Commit Hash is $($VersionData.LastCommitHash), remote is $LastCommitHash"
        Write-Host "-----------------------------------------"

        # Show diff if $NewFile is False
        if ($NewFile -eq $false) {

            $CurrentProfile = Get-Content $PSCachedProfile
            $NewProfile = $Gist.files.PSProfile.content.Split([Environment]::NewLine)

            Write-Host "[+] Added Lines"
            $NewProfile | % { if ($_ -notin $CurrentProfile) {Write-Host $_ -ForegroundColor Green}}

            Write-Host "[+] Removed Lines"
            $CurrentProfile | % { if ($_ -notin $NewProfile) {Write-Host $_ -ForegroundColor Red}}
        }

        # Present options to accept or reject changed profile
        $Deny = New-Object System.Management.Automation.Host.ChoiceDescription '&Deny','Do not allow loading of the new profile'
        $Allow = New-Object System.Management.Automation.Host.ChoiceDescription '&Allow','Allow loading of the new profile'
        $Choices = [System.Management.Automation.Host.ChoiceDescription[]]($Deny,$Allow)

        $Prompt = 'Do you wish to allow loading the changed profile?'
        $Result = $Host.UI.PromptForChoice($null, $Prompt, $Choices, 0)
    
        if ($Result -eq 1) {
            $LoadProfile = [ProfileLoadOption]::LoadRemote
            
            # Upading local version file
            $VersionData.LastModified = $LastModified
            $VersionData.LastCommitHash = $LastCommitHash
            $VersionData | ConvertTo-Json | Out-File $PSRemoteProfileVersionFile -Force

            # Upading cached profile
            $Gist.files.PSProfile.content | Out-File $PSCachedProfile -Force
        } 
        else {
            Write-Host "Loading remote profile rejected, falling back to locally cached version"
            $LoadProfile = [ProfileLoadOption]::LoadCached
        }
    } else {
        $LoadProfile = [ProfileLoadOption]::LoadCached
    }

    Write-Output $LoadProfile
}

$PSCachedProfile = ([System.IO.FileInfo]$profile).DirectoryName + "\PSCachedPofile.ps1"

try {
    [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
    $Gist = Invoke-RestMethod https://api.github.com/gists/$ProfileGistID
    $LoadProfile = VerifyRemoteProfile -Gist $Gist -PSCachedProfile $PSCachedProfile

    # Load remote profile
    if ($LoadProfile -eq "LoadRemote") {
        Write-Host "Loading remote profile" -ForegroundColor Green
        Invoke-Expression ($Gist).files.PSProfile.content
    }

    # Load cached profile
    if ($LoadProfile -eq "LoadCached") {
        . $PSCachedProfile
    }

} catch {
    # Load cached version in the event of an error
    if (!(Test-Path $PSCachedProfile)) {
        Write-Warning "Locally cached copy of remote profile not found, expected at $PSCachedProfile"
    } else {
        . $PSCachedProfile
    }
}

# Clean up after ourselves
Remove-Variable Gist, LoadProfile, ProfileGistID, PSCachedProfile -ErrorAction SilentlyContinue

That’s a little long, so let’s go through it.

First, the only change we need to make is to set the $ProfileGistID variable to our GitHub Gist Id. From there a function is defined (VerifyRemoteProfile) that determines whether the remote profile has changed, and if so, prompts us to approve the updated script.

The Last Modified and Commit SHA Hash values are stored in a JSON file in our $Profile path - this means changes will need to be approved for each version of PowerShell installed - this is intentional.

If no changes are present, the locally cached profile (also stored in the $Profile path, named PSCachedPofile.ps1) is loaded.

The bad news is we need to have the above code in each PowerShell profile script we want to keep synchronized. The good news is that we only need to do it once, and even better, we can keep a copy inside a comment block at the bottom of the Gist where our profile is stored, for convenience.

PowerShell profile Gist with update function

Example usage

Here is an example of opening a new PowerShell window for the first time after creating our Gist and putting the above code in our profile.

PowerShell loading new profile

In this example we open a PowerShell window after making changes to the Gist. We can see I added a function to overwrite the default Get-Help with my own, however, it doesn’t look quite right.

PowerShell loading changed profile

The reason it doesn’t look right is our (my) primitive (bad) diffing method, what’s missing are ‘common’ or frequently appearing lines (such as } else {) that are already present in other parts of the current file - these don’t show as they’re already present. Here is the actual function for comparison.

function Get-Help {
    if ($args) {
        start "https://www.google.com/search?q=$args"
    } else {
        start "https://www.google.com/"
    }
}

This is something to be aware of as your changes won’t be shown in all their glory, but there will be enough info to clearly tell if something is off.

Finally, opening PowerShell when the remote profile has not changed, no additional output is shown, the locally cached profile is loaded silently.

PowerShell loading unchanged

Conclusion

I think this is an adequately safe and convenient method of synchronizing PowerShell profiles across machines, but I’d love to hear if anyone has thoughts on how it can be abused (or improved).

Lastly, I’ve left my example GitHub Gist in place, in case you wish to use it as a reference.