Build an Azure DevOps pipeline to automatically sign your PowerShell scripts

5 minute read

Too lazy to sign your PowerShell scripts? Yes of course it provides security benefits but performing the steps manually can be easily forgotten and re-signing needs to happen after every script change. Because I like CI/CD topics and have not found a solution on the internet I decided to build a solution based on Azure capabilities. Furthermore, I wanted a solution which does not require to hand out the code signing certificate to the respective script author which can be useful if you have a bunch of people writing PowerShell scripts.

From a personal perspective, I would also recommend signing scripts you hand over to customers to ensure the integrity of the scripts because as soon as the script gets changed the signature is invalid.

You can find more general recommendations about script signing in the PowerShell docs.

Solution overview

The key vault will store the code signing certificate with an access policy that allows access from the Azure DevOps pipeline.

The pipeline consists of the following steps:

  • Import code signing certificate
    • The certificate is supplied as secret in a variable group which is linked to the key vault
    • Access to the key vault is granted with a service connection (service principal)
  • Sign PowerShell scripts which contain a “magic token”
    • All *.ps1 within the attached repository will be enumerated
    • To control which scripts will be signed only those with the “magic token” get processed
    • The “magic token” gets removed before signing the script
  • Publish signed PowerShell scripts as pipeline artifacts
    • To make them available for download

Solution overview

This solution has the advantage that you don’t need to hand out your code signing certificate to your team which authors the scripts and you have an automated and stable way to sign your scripts including a clear history of the scripts which were signed.

If you want to have a look at the pipeline configuration you can find it on my GitHub account. I added all the PowerShell as inline scripts but you could also convert them to external scripts if you want to run additional pipeline steps and maintain a cleaner YAML.

To store a certificate from a key vault directly in a certificate store without creating any files I found the following overload for the X509Certificate2.Import() method which accepts a Byte[] array with X509KeyStorageFlags to ensure that the private key gets imported as well:

Import(Byte[], SecureString, X509KeyStorageFlags) docs

$secret = Get-AzKeyVaultSecret -VaultName "vault" -Name "CodeSigningCert"
$secretBytes = [System.Convert]::FromBase64String($secret.SecretValueText)

# Build pfx
$pfxcert = New-Object -TypeName System.Security.Cryptography.X509Certificates.X509Certificate2
$keyStoreFlags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable `
    -bxor [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::UserKeySet `
    -bxor [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::PersistKeySet;
$pfxcert.Import($secretBytes, $null, $keyStoreFlags);

# import to personal store
$store = New-Object -TypeName System.Security.Cryptography.X509Certificates.X509Store -ArgumentList @("My", "CurrentUser")
$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite);
$store.Add($pfxcert)
$store.Close()

In the pipeline, we won’t use the Get-AzKeyVaultSecret cmdlet because we access the key vault with a variable group that has a reference to the key vault.

To process all PowerShell scripts in the attached repository I added the following code and used #PerformScriptSigning as “magic token” to identify the scripts I want to sign.

The signed scripts are copied to the $env:Build_ArtifactStagingDirectory which is available within the pipeline to store artifacts we want to publish.

$magicToken = "#PerformScriptSigning"
$encoding = "UTF8"
$scriptFolder = "."
$scripts = Get-ChildItem -Path $scriptFolder -Filter "*.ps1" -Recurse -ErrorAction Stop

foreach ($script in $scripts) {
    try {
        $content = Get-Content -Path $script.FullName -Encoding $encoding

        if ($content.Contains($magicToken)) {
            $content = $content | Where-Object {$_ -notmatch $magicToken}
            Set-Content -Value $content -Path $script.FullName -Encoding $encoding -Force
            # load cert
            $codeSigningCert = Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert | Select-Object -First 1
            Write-Output "Signing script `"$($script.Name)`" with certificate `"$($codeSigningCert.Thumbprint)`""
            # sign script
            $null = Set-AuthenticodeSignature -Certificate $codeSigningCert -FilePath $script.FullName -TimestampServer "http://timestamp.comodoca.com/rfc3161"
            # copy to artifact staging location
            $null = Copy-Item -Path $script.FullName -Destination $env:Build_ArtifactStagingDirectory
        }
    }
    catch {
        Write-Error $_
    }
}

One very small but important detail is to use the TimestampServer parameter for the Set-AuthenticodeSignature cmdlet because if you use a timestamp server the signature remains valid even after your code signing certificate has expired.

You can find the full pipeline configuration here which includes the mentioned PowerShell snippets optimized for the pipeline.

Prerequisites

  • Code Signing Certificate
  • Azure Key Vault
  • Git repo with your PowerShell scripts
    • You can also host your repo directly on Azure DevOps
  • Azure DevOps for the pipeline
    • Free trial for up to 5 contributors is available

Key vault

Upload your PFX code signing certificate to the key vault under the certificate tab and choose a descriptive name: Upload Code Signing cert

Azure DevOps Pipeline

Optionally you can create a new Azure DevOps project or use an existing one.

  • Add a new pipeline Create Pipeline

  • Select repo which contains the PowerShell script (if you want to use the default Azure DevOps repo make sure to initialize it first) Select repo

  • Choose the starter pipeline Starter

  • Replace the script with the following YAML content and save the pipeline YAML

Variable Group

We now connect our variable group (can be found under the library submenu) to the key vault which holds the code signing certificate. This allows us to retrieve the certificate from the pipeline. If you didn’t adjust the YAML file you should use CodeSigning as the variable group name because it’s referenced within the pipeline configuration.

To access the key vault you need to select the subscription and resource group and hit the “Authorize” button which will create a new service principal in the background for Azure DevOps to interact with Azure resources.

variable group

Intermediate and root CA certificates

If your code signing certificate requires an intermediate ca / root ca certificate you can add them as secure files. Name them RootCA and IntermediateCA and uncomment the section in the YAML file to import those in the pipeline. The PowerShell signing Process requires a valid certificate chain.

  • Upload optional root CA & intermediate CA public keys as secure files Upload

  • Uncomment the following section of the pipeline which installs the certificates on the agent: Code

The solution in action

If you want to sign a script add the following token to the script #PerformScriptSigning (preferably within the first few lines):

#PerformScriptSigning
Write-Host "Hello"

After you committed & pushed your changes with git the pipeline will start and perform the certificate retrieval and signing process.

You can find and download the signed scripts under the pipeline artifacts:

Artifacts

Signed scripts

Testing the signature can be done with PowerShell and the Get-AuthenticodeSignature cmdlet:

Test Script Signature

As the pipeline runs automatically on every pushed commit it’s quite easy to always use and deliver signed scripts for your future projects. Hope this saves you some time an encourages you to sign your PowerShell scripts.

Comments