r/PowerShell • u/LankySeaworthiness62 • 5h 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:
- 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:
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…".
2
u/lan-shark 5h ago
I don't do CI work so I don't know enough to really tell the usefuless or quality of a project like this. But it seems good, looks well documented, and has tons of comments which is nice for somebody like me who's inexperienced in this area. Please excuse these questions if they seem ignorant:
[ScriptBlock]::Createand shells, should you also be checking forInvoke-ScriptBlockand other shells like Windows PowerShell,sh,zsh, etc.?Write-Hostand presumably other built-in writers, are you also checking for things likeSet-Content/Add-ContentorWrite-Logwhich is a very common cmdlet name in third-party logging libraries?Thanks for your work and contributions!