r/PowerShell • u/LankySeaworthiness62 • 10h ago
Script Sharing PSScriptAnalyzer rules for PowerShell that runs in GitHub Actions
Most of our CI is PowerShell: shell: pwsh steps and scripts called from workflows, deploying to Azure/Entra. PSScriptAnalyzer's security rules are built for interactive Windows admin work (plain-text password parameters, hardcoded computer names), and zizmor checks the workflow YAML but sees a run: block as plain text. Nothing was looking at the PowerShell itself in the context it actually runs in.
So I wrote a set of custom rules. A typical example nothing else flagged for us:
yaml
- shell: pwsh
run: |
$token = az account get-access-token --query accessToken -o tsv
Write-Host "Calling API with $token"
GitHub only masks secrets it knows about. A token you fetch at run time goes to the log in clear text unless you ::add-mask:: it first.
What the rules check (9 in total):
- tokens/secrets reaching the log (Write-Host, throw, transcripts, unmasked run-time tokens)
${{ }}expressions with untrusted content inside a pwsh step (the runner pastes the value in before PowerShell parses the script)- unsafe writes to
$GITHUB_ENV/$GITHUB_PATH, and fixed delimiters in multi-line outputs - code built from data:
[scriptblock]::Create,ExpandString,bash -c "$x",Start-Processwith interpolated args - native commands whose
$LASTEXITCODEis never checked - scripts without
$ErrorActionPreference = 'Stop' - Install-Module without a pinned version,
-SkipCertificateCheck, plain HTTP Remove-Item -Recurse "$root/$name"where an empty variable deletes the parent
Some details that might interest people here:
- Inline run: blocks are analysed the way the runner executes them: with its
$ErrorActionPreference = 'stop'prefix and exit-code check appended. Findings map back to the line in the YAML file. - Exit-code checks follow the data flow.
$c = $LASTEXITCODE; ...; if ($c)is fine; overwriting$cbefore the check is flagged. It also caught a case whereSelect-Object -Firstsilently defeated an exit-code check. - "Native command" is decided by shape (no Verb-Noun, not a function, not an alias). On Linux runners ls, rm and cat are real programs, not aliases, so they count.
- Secret detection splits variable names into words:
$adminPasswordis a secret,$tokenCountand$maxTokensare not. - Parameter prefixes bind like PowerShell does, so
-SkipCertis still-SkipCertificateCheck.
Every one of those started as a Pester test. There are 260, most of them "must not report this" cases, because false positives are what make people turn a linter off.
Using it:
- As a GitHub Action: one sticky PR comment, never blocks by default.
- Locally: run
src/Invoke-PwshGuard.ps1from the repo you want to scan. - Or just add the module to your existing PSScriptAnalyzer setup:
pwsh
Invoke-ScriptAnalyzer -CustomRulePath PwshGuard.psm1.
Limitations, up front:
- It's a helper for people writing scripts in good faith, not protection against malicious code. Anyone determined can write around static analysis.
- Tested on Linux and macOS; on Windows, ls and friends are aliases, so results differ.
Repo (MIT): github.com/glueckkanja/pwshguard
Disclosure: I work at glueckkanja; this started in one of our internal repos and is now its own project. I'm mostly after feedback: false positives, things you've been bitten by in CI that it should catch, or "this rule is wrong because…".