Secure coding: 4 guard rails for your PowerShell scripts

Interviewed by AzSecurity on Youtube!

I recently sat down with @AzSecurity for an episode of AzSecurity – Learn by doing. We had a great deep dive into Secure Coding for PowerShell.

Below is a summary of the secure coding 4 pillars we discussed during the podcast.


Secure coding = guard rails for your PowerShell script

2:14 a.m. The cleanup script that “just removes empty test resource groups” runs on schedule. The variable that should hold a single resourceGroupName is $null, because the Graph call timed out an hour ago (network unreachable). The next line in the script is Remove-AzResourceGroup. By 2:15 a.m., standby gets a call.

Secure coding isn’t only about keeping hackers out of your resources; it’s about protecting the environment from the script itself.

In the world of automation, secure coding means writing scripts that are resilient, predictable, and run with the least privilege they can get away with.

The four pillars below are your guard rails.


The 4 pillars

  1. Input validation
  2. Fail-safe defaults
  3. Secrets management
  4. The principle of least privilege

And of course there are more!


Pillar 1 – Input validation

  • The Goal: Never trust your input, even if it comes from another script.
  • Secure Coding Practice: Use type-constrained parameters and validation attributes ([ValidateNotNullOrEmpty()], [ValidatePattern()], Mandatory).
  • The Lesson: A secure script fails fast with an error rather than failing silently and deleting the wrong resource.

Never assume the data entering your script is correct or safe, even if it comes from a “trusted” internal source.

An empty variable being passed to a destructive command is exactly how a single-resource cleanup turns into a tenant-wide cleanup.

Bad validation:
  • No validation

The below example will delete all resource groups when the parameter $ResourceGroupName is empty or $null.

function Remove-DemoResourceGroup {
    param(
        # No Mandatory, no validation, no type constraint.
        $ResourceGroupName
    )

    Write-Verbose "About to delete prefix: '$ResourceGroupName'"

    $query = "ResourceContainers | where type == 'microsoft.resources/subscriptions/resourcegroups' | where name startswith '$ResourceGroupName' | project name"
    $resourceGroups = Search-AzGraph -Query $query

    foreach ($rg in $resourceGroups) {
        Remove-AzResourceGroup -Name $rg.name -Force
    }
}
Good validation:
  • [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
  • [Parameter(Mandatory)]
  • [ValidateNotNullOrEmpty()]
  • [ValidatePattern('^rg-[a-z0-9-]{3,60}$')]
  • Set-StrictMode -Version Latest

The below example will not delete any resource Group that does not passes the tests above.

function Remove-DemoResourceGroup {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        # ^rg-              -- must start with the literal "rg-" prefix
        # [a-z0-9-]{3,60}   -- followed by 3 to 60 chars, only lowercase
        #                      letters, digits, or hyphens
        # $                  -- nothing else after that (no spaces, no
        #                      wildcards, no slashes, no ;, no quotes)
        # Net effect: rejects $null, '', 'rg-*', 'rg-prod;rm -rf', etc.
        [ValidatePattern('^rg-[a-z0-9-]{3,60}$')]
        [string]$ResourceGroupName
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    Write-Verbose "About to delete prefix: '$ResourceGroupName'"

    $query = "ResourceContainers | where type == 'microsoft.resources/subscriptions/resourcegroups' | where name startswith '$ResourceGroupName' | project name"
    $resourceGroups = Search-AzGraph -Query $query
    if ($resourceGroups.Count -eq 0) {
        write-host "Resource group '$ResourceGroupName' does not exist. Nothing to do."
        return
    }
    foreach ($rg in $resourceGroups) {
        if ($PSCmdlet.ShouldProcess($rg.name, 'DELETE Resource Group')) {
            Remove-AzResourceGroup -Name $rg.name -Force
        }
    }
}

Pillar 2 – Fail-safe defaults

Bad fail-safe defaults:
  • No fail-safe defaults

An operator picks the tag name, one typo and the filter silently matches NOTHING, yet it will still say ‘All Done!’.

function Remove-DemoTaggedResourceGroup {
    param(
        $Prefix = 'defps-demo',
        $TagKey = 'demoOwner'   # 
    )
    $rgs = Get-AzResourceGroup -ErrorAction SilentlyContinue |
    Where-Object { $_.Tags.$TagKey -eq 'defensive-ps' -and $_.ResourceGroupName -like "rg-$Prefix-delete-*" }

    foreach ($rg in $rgs) {
      Remove-AzResourceGroup -Name $rg.ResourceGroupName -Force -ErrorAction SilentlyContinue
    }

    Write-Host "All done!" -ForegroundColor Green
}
Good fail-safe defaults
  • $ErrorActionPreference = 'Stop'
  • An extra check to see if the variable contains items before continuing (with a Write-Warning / return, can also be a throw)
  • And no “all Done!’ when it didn’t do anything or errored out.
function Remove-DemoTaggedResourceGroup {
  [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
  param(
    [ValidateNotNullOrEmpty()]
    [string]$Prefix = 'defps-demo',

    [ValidateNotNullOrEmpty()]
    [string]$TagKey = 'demooOwner'
  )

  Set-StrictMode -Version Latest
  $ErrorActionPreference = 'Stop'

  try {
    $rgs = Get-AzResourceGroup |
    Where-Object {
      $_.Tags -and
      $_.Tags.ContainsKey($TagKey) -and
      $_.Tags[$TagKey] -eq 'defensive-ps' -and
      $_.ResourceGroupName -like "rg-$Prefix-delete-*"
    }
  } catch {
    throw 
  }

  if (-not $rgs) {
    Write-Warning "No matching resource groups found (Prefix='$Prefix', TagKey='$TagKey'). Nothing to do."
    return
  }

  foreach ($rg in $rgs) {
    if ($PSCmdlet.ShouldProcess($rg.ResourceGroupName, 'DELETE Resource Group')) {
      try {
        Remove-AzResourceGroup -Name $rg.ResourceGroupName -Force
      } catch {
        throw
      }
    }
  }

  Write-Host "All done!" -ForegroundColor Green
}

Pillar 3 – Secrets management

  • The Goal: Never expose credentials, secrets, tokens, or connection strings.
  • Secure Coding Practice: Pull secrets at run time from Azure Key Vault, and authenticate with a Managed Identity.
  • The Lesson: A hardcoded secret in a script, even in a private repo, is a violation of secure coding.

Note: testing or debugging also needs secrets management, otherwise it’s exposed locally in your history files.

Bad secrets management:
  • No secrets management
$apiKey = 'sk-live-9f2a47b1-DO-NOT-COMMIT-ME-3c8d'
Write-Host "Calling fake API with key: $apiKey"

Get-Content .\Bad-HardcodedSecret.ps1 | Select-String 'sk-live'

$apiKey = 'sk-live-9f2a47b1-DO-NOT-COMMIT-ME-3c8d'

And PSReadLine logs every command you TYPE (not the expanded value). So a script reference like $apiKey is harmless in history — but the moment you paste a literal secret into the terminal, it lands on disk in plain text.

Try it:

$key = 'sk-live-9f2a47b1-PASTED-INTO-TERMINAL'
Get-Content (Get-PSReadLineOption).HistorySavePath -Tail 2

$key = 'sk-live-9f2a47b1-PASTED-INTO-TERMINAL'

And (Get-PSReadLineOption).HistorySavePath shows you a file path, where all of your commands ever called are stored in. You should open it and check it out!

(Get-PSReadLineOption).HistorySavePath

C:\Users\baswi\AppData\Roaming\Microsoft\Windows\PowerShell\PSReadLine\ConsoleHost_history.txt
Good secrets management:
  • Use a key vault or Microsoft.PowerShell.SecretManagement module
  • Keep the secret as a secureString or PSKeyVaultSecret
  • Do not even output it on your local device
  • Dispose the secret from memory

A secret from the Azure key vault retrieved with Get-AzKeyVaultSecret becomes a SecureString in memory, never a plaintext string on disk or in history.

[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [ValidateNotNullOrEmpty()]
    [string]$VaultName,
    [ValidateNotNullOrEmpty()]
    [string]$SecretName = 'demo-api-key'
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$secureValue = (Get-AzKeyVaultSecret -VaultName $VaultName -Name $SecretName).SecretValue

try {
    Invoke-RestMethod -Uri 'https://api.example.com/...' -Headers @{
        'x-api-key' = (ConvertFrom-SecureString -SecureString $secureValue -AsPlainText)
    }
}
finally {
    # Remove from Memory when finished
    if ($secureValue) { $secureValue.Dispose() }
}

Pillar 4 – The principle of least privilege

  • The Goal: Your script should only have the permissions it needs for the specific task.
  • Secure Coding Practice: Don’t use Global Administrator for a script that just reads a few Graph properties. Pick the smallest scope from the Microsoft Graph permissions reference.
  • The Lesson: Secure code is useless if the identity running it has “God-mode” enabled.
Bad principle of least privilege:
  • Using Global Administrator or Owner for EVERYTHING!

You can always check your jwt token and see which permissions you have with jwt.ms or use this as an example:

Connect-MgGraph -Scopes 'Directory.ReadWrite.All' -NoWelcome
$probe = Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/v1.0/me' -OutputType HttpResponseMessage
$token = $probe.RequestMessage.Headers.Authorization.Parameter

$payload = $token.Split('.')[1]
$payload += '=' * ((4 - $payload.Length % 4) % 4)   # base64 padding
$claims = [Text.Encoding]::UTF8.GetString(
    [Convert]::FromBase64String($payload.Replace('-', '+').Replace('_', '/'))
) | ConvertFrom-Json
Write-Host "Token scopes (scp): $($claims.scp)"

Token scopes (scp): Agreement.Read.All Application.ReadWrite.All CrossTenantInformation.ReadBasic.All DeviceManagementApps.Read.All DeviceManagementConfiguration.Read.All DeviceManagementRBAC.Read.All DeviceManagementServiceConfig.Read.All Directory.Read.All Directory.ReadWrite.All email openid Policy.Read.All PrivilegedEligibilitySchedule.Read.AzureADGroup profile Reports.Read.All RoleAssignmentSchedule.Read.Directory RoleEligibilitySchedule.Read.Directory User.Read User.Read.All
Good principle of least privilege:
  • Have the least privileges…

Functional vs “Secure” Code

FeatureFunctional Code (the “lazy” way)Secure CodeWhy
Variables$User = Get-GraphUser -Name $inputif ($null -eq $input) { throw "No input!" }Prevents **unintended scope execution**. Without this check, a `$null` variable can cause commands to target *all* resources
ErrorsScript ignores errors and continues.try { ... } catch { throw "Stop!" }Ensures **deterministic behaviour**. Stops the script from executing logic against a corrupted or incomplete state.
FilteringFetches everything, filters in PowerShell.Uses `$filter` in the Graph API (server-side).Enforces **data minimisation**. Less sensitive data in local memory means less to leak via logs, dumps, or crash reports.
CredentialsStored in a variable or in the .ps1.Key Vault or via Managed Identity.**Stops Secret Leaks:** Prevents secrets from being exposed on disk, in console history, or in **Git/Cloud history** where they remain recoverable even after deletion.

If you put a password in your script, it’s not just in the file. It’s in your VS Code history, your PowerShell console history, and your process memory, but it gets even riskier:

If that file is on OneDrive or a cloud drive, that secret is now synced to every device you own and in version history.

Even if you delete the secret later, it’s still there in an older version waiting to be found.

Secure coding in PowerShell: Writing Scripts That Don't Destroy Your Tenant
Secure coding in PowerShell: Writing Scripts That Don’t Destroy Your Tenant

And if it hits a Git repository, it’s practically permanent. Git is designed to remember every version of every file; even if you delete the secret in the next commit, it’s still there in the history for anyone, or any automated bot, to find.

Secure coding in PowerShell: Writing Scripts That Don't Destroy Your Tenant
Secure coding in PowerShell: Writing Scripts That Don’t Destroy Your Tenant

Once a secret hits a repo, you don’t just “delete” it; you have to rotate it immediately.

If a hacker or even a curious insider gets access, those plain-text secrets are the first thing they look for. Using Managed Identity or Key Vault keeps that sensitive info off your machine and out of your cloud history entirely.


Disclaimer and interesting items missing in blog and demo:

Disclaimer:

  • Demo scripts use Write-Host for readability in an interactive terminal
  • Set-StrictMode is on scope, but because I’m running bad- and good- scripts, I added it to each function

Items missing:

  • PSScriptAnalyzer PSAvoidUsingPlainTextForPasswordPSAvoidUsingConvertToSecureStringWithPlainTextPSAvoidUsingInvokeExpression
  • Script signing – Set-AuthenticodeSignature
  • Secret scanning in GitHub
  • Invoke-Expression classic PS injection vector.

Thanks to Dinant for letting me join the AzSecurity podcast! It was a pleasure sharing my thoughts on Secure Coding with his audience.

Published by

Bas Wijdenes

My name is Bas Wijdenes and I work as a PowerShell DevOps Engineer. In my spare time I write about interesting stuff that I encounter during my work.

Leave a Reply

Your email address will not be published. Required fields are marked *